html2rss 0.33.0 → 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 (177) 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 -21
  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 +126 -50
  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 -39
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
  19. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
  20. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
  21. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
  22. data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
  23. data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
  25. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
  27. data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
  28. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  29. data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
  30. data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
  31. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
  32. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
  33. data/lib/html2rss/auto_source/scraper.rb +10 -2
  34. data/lib/html2rss/auto_source/segment.rb +19 -5
  35. data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
  36. data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
  37. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  38. data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
  39. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  40. data/lib/html2rss/auto_source.rb +81 -26
  41. data/lib/html2rss/batch.rb +2 -0
  42. data/lib/html2rss/capture/README.md +5 -2
  43. data/lib/html2rss/capture.rb +44 -32
  44. data/lib/html2rss/channel.rb +30 -12
  45. data/lib/html2rss/cli.rb +22 -11
  46. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  47. data/lib/html2rss/config/issue_mapper.rb +4 -1
  48. data/lib/html2rss/config/request_controls.rb +6 -3
  49. data/lib/html2rss/config/schema.rb +15 -5
  50. data/lib/html2rss/config/selectors_validator.rb +2 -2
  51. data/lib/html2rss/config/validation_issue.rb +4 -1
  52. data/lib/html2rss/config/validator.rb +11 -0
  53. data/lib/html2rss/config.rb +53 -27
  54. data/lib/html2rss/contract_error.rb +9 -0
  55. data/lib/html2rss/defaults.rb +21 -23
  56. data/lib/html2rss/doctor/botasaurus.rb +7 -3
  57. data/lib/html2rss/error.rb +10 -0
  58. data/lib/html2rss/error_kind.rb +39 -0
  59. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  60. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  61. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  62. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  63. data/lib/html2rss/feed_builder/rss.rb +49 -6
  64. data/lib/html2rss/feed_builder.rb +2 -1
  65. data/lib/html2rss/feed_pipeline/README.md +1 -1
  66. data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
  67. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  68. data/lib/html2rss/feed_pipeline.rb +40 -27
  69. data/lib/html2rss/feed_resolution/README.md +1 -1
  70. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  71. data/lib/html2rss/feed_resolution/policy.rb +1 -1
  72. data/lib/html2rss/feed_resolution/probe.rb +3 -1
  73. data/lib/html2rss/feed_resolution.rb +11 -12
  74. data/lib/html2rss/feed_result.rb +9 -5
  75. data/lib/html2rss/hash_util.rb +3 -3
  76. data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
  77. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
  78. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  79. data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
  80. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  81. data/lib/html2rss/html/article_extractor.rb +19 -47
  82. data/lib/html2rss/html/article_rules/category.rb +3 -3
  83. data/lib/html2rss/html/article_rules/description.rb +6 -15
  84. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  85. data/lib/html2rss/html/article_rules/image.rb +30 -16
  86. data/lib/html2rss/html/feed_link.rb +7 -9
  87. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  88. data/lib/html2rss/html/navigator.rb +39 -9
  89. data/lib/html2rss/html/probe.rb +4 -0
  90. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  91. data/lib/html2rss/html/rendering.rb +2 -9
  92. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  93. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  94. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  95. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  96. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  97. data/lib/html2rss/html/title_pieces.rb +169 -0
  98. data/lib/html2rss/html/visibility.rb +56 -0
  99. data/lib/html2rss/html.rb +2 -0
  100. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  101. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  102. data/lib/html2rss/link_destination/path_classifier.rb +100 -9
  103. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  104. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  105. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  106. data/lib/html2rss/mcp/README.md +13 -1
  107. data/lib/html2rss/mcp/config_argument.rb +5 -2
  108. data/lib/html2rss/mcp/contract.rb +13 -4
  109. data/lib/html2rss/mcp/outcome.rb +14 -12
  110. data/lib/html2rss/mcp/runtime.rb +39 -2
  111. data/lib/html2rss/mcp/server.rb +13 -4
  112. data/lib/html2rss/page_recon/README.md +1 -1
  113. data/lib/html2rss/page_recon/diagnostics.rb +2 -0
  114. data/lib/html2rss/recon.rb +10 -12
  115. data/lib/html2rss/request_service/blocked_surface.rb +2 -1
  116. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  117. data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
  118. data/lib/html2rss/request_service/context.rb +15 -14
  119. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  120. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  121. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  122. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  123. data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
  124. data/lib/html2rss/request_service/policy.rb +48 -11
  125. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  126. data/lib/html2rss/request_service/response.rb +30 -13
  127. data/lib/html2rss/request_service/response_cache.rb +109 -0
  128. data/lib/html2rss/request_service/response_caching.rb +95 -0
  129. data/lib/html2rss/request_service/strategy.rb +5 -11
  130. data/lib/html2rss/request_service.rb +71 -12
  131. data/lib/html2rss/request_session/pager/base.rb +0 -7
  132. data/lib/html2rss/request_session/pager.rb +1 -1
  133. data/lib/html2rss/request_session.rb +6 -2
  134. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  135. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  136. data/lib/html2rss/scoring/engine.rb +6 -6
  137. data/lib/html2rss/scoring/observation.rb +2 -2
  138. data/lib/html2rss/selector_candidates.rb +19 -8
  139. data/lib/html2rss/selectors/README.md +1 -1
  140. data/lib/html2rss/selectors/option_spec.rb +4 -1
  141. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  142. data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
  143. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  144. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  145. data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
  146. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  147. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  148. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  149. data/lib/html2rss/selectors.rb +53 -12
  150. data/lib/html2rss/sst/attrs.rb +2 -2
  151. data/lib/html2rss/sst/document.rb +3 -3
  152. data/lib/html2rss/sst/index.rb +30 -11
  153. data/lib/html2rss/sst/node.rb +33 -26
  154. data/lib/html2rss/sst/normalizer.rb +31 -19
  155. data/lib/html2rss/sst/tags.rb +113 -2
  156. data/lib/html2rss/sst/text.rb +69 -29
  157. data/lib/html2rss/sst.rb +2 -0
  158. data/lib/html2rss/status.rb +6 -3
  159. data/lib/html2rss/surface_category.rb +1 -1
  160. data/lib/html2rss/syndication/README.md +12 -6
  161. data/lib/html2rss/syndication/alternate_links.rb +1 -1
  162. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  163. data/lib/html2rss/syndication/discovery.rb +82 -28
  164. data/lib/html2rss/syndication/feed.rb +20 -0
  165. data/lib/html2rss/syndication/item_media.rb +103 -0
  166. data/lib/html2rss/syndication/parser.rb +75 -51
  167. data/lib/html2rss/test/enhance_audit.rb +2 -2
  168. data/lib/html2rss/test/item_sample.rb +43 -0
  169. data/lib/html2rss/test.rb +42 -14
  170. data/lib/html2rss/url.rb +115 -14
  171. data/lib/html2rss/version.rb +1 -1
  172. data/lib/html2rss/word_count.rb +53 -0
  173. data/lib/html2rss.rb +17 -8
  174. data/schema/html2rss-config.schema.json +56 -41
  175. metadata +31 -4
  176. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  177. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
@@ -19,11 +19,40 @@ module Html2rss
19
19
  ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
20
20
  ].to_set.freeze
21
21
 
22
- # Schema.org types emitted only as elements of an ItemList (never standalone).
23
- ITEM_LIST_TYPES = Set['Event', 'JobPosting', 'VideoObject', 'PodcastEpisode'].freeze
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
24
53
 
25
- # Schema.org types whose own `contentUrl` is the media enclosure.
26
- MEDIA_TYPES = Set['VideoObject'].freeze
54
+ # Schema.org types emitted only as elements of an ItemList (never standalone).
55
+ ITEM_LIST_TYPES = Set['Event', 'JobPosting'].freeze
27
56
 
28
57
  # @param schema_object [Hash{Symbol => Object}] parsed schema.org object
29
58
  # @param url [String, Html2rss::Url, nil] base URL used for relative normalization
@@ -32,9 +61,10 @@ module Html2rss
32
61
  @base_url = normalized_base_url(url)
33
62
  end
34
63
 
35
- # @return [Hash{Symbol => Object}] scraped article attributes
64
+ # @return [Hash{Symbol => Object}] scraped article attributes; a media object's carry `media: true`
36
65
  def call
37
- { id:, title:, description:, author:, url:, image:, published_at:, categories:, enclosures: }
66
+ item = { id:, title:, description:, author:, url:, image:, published_at:, categories:, enclosures: }
67
+ own_media? ? item.merge(media: true) : item
38
68
  end
39
69
 
40
70
  # @return [String, nil] stable schema object identifier
@@ -94,9 +124,10 @@ module Html2rss
94
124
  @categories ||= CategoryExtractor.call(schema_object)
95
125
  end
96
126
 
97
- # @return [Array<Hash{Symbol => Object}>, nil] enclosures from VideoObject/AudioObject `contentUrl`
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
98
129
  def enclosures
99
- nodes = Array(schema_object.values_at(:video, :audio)).flatten
130
+ nodes = schema_object.values_at(*MEDIA_KEYS).flatten
100
131
  nodes << schema_object if own_media?
101
132
  found = nodes.filter_map { media_enclosure(_1) }
102
133
  found unless found.empty?
@@ -111,28 +142,37 @@ module Html2rss
111
142
 
112
143
  private
113
144
 
114
- # @return [Boolean] whether the object itself is a media object carrying its own `contentUrl`
115
- def own_media?
116
- Schema.normalize_types(schema_object[:@type]).intersect?(MEDIA_TYPES)
117
- end
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)
118
147
 
119
- # @param node [Object] VideoObject/AudioObject node
120
- # @return [Hash{Symbol => Object}, nil] enclosure attributes, or nil without a usable absolute contentUrl
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})
121
151
  def media_enclosure(node)
122
- return unless node.is_a?(Hash) && node[:contentUrl].is_a?(String)
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')
123
154
 
124
- build_media_enclosure(node[:contentUrl], node[:encodingFormat])
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))
125
158
  end
126
159
 
127
- # @param src [String] contentUrl
128
- # @param type [Object] encodingFormat (used when a String)
129
- # @return [Hash{Symbol => Object}, nil] enclosure attributes with an absolute url
130
- def build_media_enclosure(src, type)
131
- found = ::Html2rss::Html::ArticleRules::Enclosure.from_media(src, (type if type.is_a?(String)),
132
- base_url || src)
133
- found if found&.dig(:url)&.absolute?
134
- rescue Url::InvalidUrlError
135
- nil
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('.')]
136
176
  end
137
177
 
138
178
  # @param value [Object] url candidate
@@ -147,11 +187,12 @@ module Html2rss
147
187
  nil
148
188
  end
149
189
 
150
- # @return [Html2rss::Url, nil] first absolute http(s) mainEntityOfPage or @id, without fragment
190
+ # @return [Html2rss::Url, nil] first absolute http(s) mainEntityOfPage or @id, without fragment; never
191
+ # `contentUrl` (a file) or `embedUrl` (a player page)
151
192
  def fallback_url
152
193
  main = schema_object[:mainEntityOfPage]
153
194
  candidates = main.is_a?(Hash) ? [main[:@id], main[:url]] : [main]
154
- (candidates + [schema_object[:@id]]).lazy.filter_map { web_url(_1) }.first
195
+ (candidates << schema_object[:@id]).filter_map { web_url(_1) }.first
155
196
  end
156
197
 
157
198
  # @param value [Object] url candidate
@@ -25,7 +25,7 @@ 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 | Thing::ITEM_LIST_TYPES |
28
+ canonical_types = Thing::ITEM_TYPES | ItemList::SUPPORTED_TYPES | Thing::ITEM_LIST_TYPES |
29
29
  DENIED_CONTAINER_TYPES | Set['Product']
30
30
  canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
31
31
  end.freeze
@@ -33,7 +33,7 @@ module Html2rss
33
33
  # Pre-compiled regex for supported schema types (short name or schema.org URL; string or array @type).
34
34
  # Allows preceding entries in a JSON @type array (e.g. ["WebPage","NewsArticle"]).
35
35
  SUPPORTED_TYPES_RE = begin
36
- types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES
36
+ types = Thing::ITEM_TYPES | ItemList::SUPPORTED_TYPES
37
37
  type_re = Regexp.union(types.to_a)
38
38
  %r{(?i)"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
39
39
  end.freeze
@@ -45,7 +45,7 @@ module Html2rss
45
45
  EMPTY_TYPES = Set.new.freeze
46
46
 
47
47
  # Keys whose `{"@id": ...}` stubs are resolved against sibling nodes of the same JSON-LD document.
48
- REFERENCE_KEYS = %i[author publisher image].freeze
48
+ REFERENCE_KEYS = (%i[author publisher image] + Thing::MEDIA_KEYS).freeze
49
49
 
50
50
  # @return [Symbol] scraper config key
51
51
  def self.options_key = :schema
@@ -115,7 +115,7 @@ module Html2rss
115
115
  # Prefer article/list extractors over denied containers for multi-type @type arrays
116
116
  # (e.g. ["NewsArticle","WebPage"]).
117
117
  return ItemList if types.intersect?(ItemList::SUPPORTED_TYPES)
118
- return Thing if types.intersect?(Thing::SUPPORTED_TYPES)
118
+ return Thing if types.intersect?(Thing::ITEM_TYPES)
119
119
  return nil if types.intersect?(DENIED_CONTAINER_TYPES)
120
120
 
121
121
  Log.debug("#{name}: unsupported schema object @type=#{schema_object[:@type].inspect}")
@@ -214,12 +214,9 @@ module Html2rss
214
214
 
215
215
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
216
216
  # @param url [String, Html2rss::Url] base page URL
217
- # @param opts [Hash] scraper-specific options
218
- # @option opts [Object] :_reserved reserved for future scraper-specific options
219
- def initialize(parsed_body, url:, **opts)
217
+ def initialize(parsed_body, url:)
220
218
  @parsed_body = parsed_body
221
219
  @url = url
222
- @opts = opts
223
220
  end
224
221
 
225
222
  # @return [Boolean]
@@ -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
@@ -32,14 +32,11 @@ module Html2rss
32
32
  # @param url [String, Html2rss::Url] base url
33
33
  # @param document [SST::Document, nil] memoized SST document from AutoSource
34
34
  # @param link_resolver [Scoring::LinkResolver, nil] shared page-scoped resolver
35
- # @param opts [Hash] scraper-specific options
36
- # @option opts [Boolean] :fallback_anchorless whether to keep containers without a primary anchor
37
- def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil, **opts)
35
+ def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil)
38
36
  @parsed_body = parsed_body
39
37
  @provided_document = document
40
38
  @provided_link_resolver = link_resolver
41
39
  @url = url
42
- @permit_unanchored = opts.fetch(:fallback_anchorless, false)
43
40
  end
44
41
 
45
42
  ##
@@ -76,7 +73,6 @@ module Html2rss
76
73
  document,
77
74
  base_url: @url,
78
75
  strategy: :semantic,
79
- permit_unanchored: @permit_unanchored,
80
76
  link_resolver:
81
77
  )
82
78
  Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
@@ -43,8 +43,9 @@ module Html2rss
43
43
  # @param url [String, Html2rss::Url] canonical page URL
44
44
  # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
45
45
  # @param body [String, nil] raw response body for direct sitemap XML parsing
46
- # @option _opts [Float] :min_priority minimum priority score (default: 0.3)
47
- # @option _opts [Integer] :max_age_days maximum entry age in days (default: 30)
46
+ # @param opts [Hash] sitemap options
47
+ # @option opts [Float] :min_priority minimum priority score (default: 0.3)
48
+ # @option opts [Integer] :max_age_days maximum entry age in days (default: 30)
48
49
  def initialize(parsed_body, url:, request_session: nil, body: nil, **opts)
49
50
  @parsed_body = parsed_body
50
51
  @body = body
@@ -135,7 +136,7 @@ module Html2rss
135
136
 
136
137
  response = request_session.follow_up(url: target_url, relation: :auto_source, origin_url: url)
137
138
  response&.body
138
- rescue Html2rss::RequestService::RequestBudgetExceeded
139
+ rescue Html2rss::RequestService::RequestBudgetExceeded, Html2rss::ContractError
139
140
  raise
140
141
  rescue Html2rss::Error => error
141
142
  Log.warn({
@@ -46,10 +46,8 @@ module Html2rss
46
46
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
47
47
  # @param url [String, Html2rss::Url] canonical page URL
48
48
  # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
49
- # @param _opts [Hash] unused scraper-specific options
50
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
51
49
  # @return [void]
52
- def initialize(parsed_body, url:, request_session: nil, **_opts)
50
+ def initialize(parsed_body, url:, request_session: nil)
53
51
  @parsed_body = parsed_body
54
52
  @url = Html2rss::Url.from_absolute(url)
55
53
  @request_session = request_session
@@ -109,6 +107,8 @@ module Html2rss
109
107
  relation: :auto_source,
110
108
  origin_url: url
111
109
  )
110
+ rescue Html2rss::ContractError
111
+ raise
112
112
  rescue Html2rss::Error => error
113
113
  Log.warn({
114
114
  event_name: 'wordpress_api.fetch_failed',
@@ -26,9 +26,7 @@ module Html2rss
26
26
  # @param _parsed_body [Nokogiri::HTML::Document, nil] unused HTML document
27
27
  # @param url [String, Html2rss::Url] page URL used to resolve relative links
28
28
  # @param captured_responses [Array<Hash>] JSON bodies from Response#captured_responses
29
- # @param _opts [Hash] scraper-specific options
30
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
31
- def initialize(_parsed_body, url:, captured_responses: [], **_opts)
29
+ def initialize(_parsed_body, url:, captured_responses: [])
32
30
  @url = url
33
31
  @captured_responses = captured_responses
34
32
  end
@@ -38,8 +38,16 @@ module Html2rss
38
38
  # Flat ordered list (request slot accounting, detection helpers).
39
39
  SCRAPERS = (DOCUMENT_SCRAPERS + SCRAPER_TIERS.flatten).freeze
40
40
 
41
- # Heuristic scrapers that share one memoized SST::Document per page.
41
+ # Heuristic scrapers that share one memoized SST::Document per page. The only titles composed
42
+ # (+Kicker: Headline+, noise stripped): their cards are read by Html::SstArticleExtractor. Every
43
+ # other scraper yields publisher-declared or structured headlines, kept verbatim (collapse only).
42
44
  HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
45
+ # In-page structured scrapers whose items {Corroboration} checks against the rendered page.
46
+ # Publisher-declared sources (NativeFeed, WordpressApi, Sitemap, MetaOembed) stay exempt.
47
+ CORROBORATED_SCRAPERS = Set[Schema, Microdata, Microformats2, JsonState, XhrArticles, JsonDocument].freeze
48
+ # Scrapers whose items are read off the rendered page: such an item needs a title ({Cleanup}
49
+ # drops it as +blank_title+), while a publisher-declared item (feed, API) may be untitled.
50
+ PAGE_SCRAPERS = (CORROBORATED_SCRAPERS | HEURISTIC_SCRAPERS).freeze
43
51
  # Scrapers that accept a shared follow-up +request_session+.
44
52
  REQUEST_SESSION_SCRAPERS = Set[NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
45
53
  # Scrapers that consume browser-captured XHR/fetch JSON bodies.
@@ -80,7 +88,7 @@ module Html2rss
80
88
  return if CATEGORY_MESSAGES.key?(category)
81
89
 
82
90
  valid_categories = CATEGORY_MESSAGES.keys.join(', ')
83
- raise ArgumentError, "Unknown category: #{category.inspect}. Valid categories are: #{valid_categories}"
91
+ raise InvalidOption, "Unknown category: #{category.inspect}. Valid categories are: #{valid_categories}"
84
92
  end
85
93
  end
86
94
 
@@ -7,6 +7,9 @@ module Html2rss
7
7
  ##
8
8
  # One candidate content block discovered by {Segmenter}.
9
9
  class Segment
10
+ # Raised when a segment (or the Segmenter producing segments) gets invalid input.
11
+ class InvalidError < Html2rss::ContractError; end
12
+
10
13
  # Allowed Segmenter strategies.
11
14
  STRATEGIES = Set[:semantic, :list, :cluster].freeze
12
15
 
@@ -16,17 +19,28 @@ module Html2rss
16
19
  # @param strategy [Symbol]
17
20
  # @param position [Integer]
18
21
  # @return [Segment]
19
- # @raise [ArgumentError] on invalid construction
22
+ # @raise [InvalidError] on invalid construction
20
23
  def self.build(root_node:, strategy:, position:, primary_link: nil)
21
- raise ArgumentError, 'root_node must be SST::Node' unless root_node.is_a?(SST::Node)
24
+ raise InvalidError, 'root_node must be SST::Node' unless root_node.is_a?(SST::Node)
22
25
  unless primary_link.nil? || primary_link.is_a?(SST::Node)
23
- raise ArgumentError, 'primary_link must be SST::Node or nil'
26
+ raise InvalidError, 'primary_link must be SST::Node or nil'
24
27
  end
25
- raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless STRATEGIES.include?(strategy)
26
- raise ArgumentError, 'position must be Integer' unless position.is_a?(Integer)
28
+
29
+ strategy!(strategy)
30
+ raise InvalidError, 'position must be Integer' unless position.is_a?(Integer)
27
31
 
28
32
  new(root_node:, primary_link:, strategy:, position:)
29
33
  end
34
+
35
+ ##
36
+ # @param strategy [Symbol]
37
+ # @return [Symbol] +strategy+ when it is one of {STRATEGIES}
38
+ # @raise [InvalidError] for any other value
39
+ def self.strategy!(strategy)
40
+ return strategy if STRATEGIES.include?(strategy)
41
+
42
+ raise InvalidError, "unknown strategy: #{strategy.inspect}"
43
+ end
30
44
  end
31
45
  end
32
46
  end
@@ -15,7 +15,7 @@ module Html2rss
15
15
  nodes = best_group_nodes(segmenter)
16
16
  nodes.each_with_index.filter_map do |node, position|
17
17
  link = primary.select(node)
18
- next unless link || segmenter.permit_unanchored
18
+ next unless link
19
19
 
20
20
  Segment.build(root_node: node, primary_link: link, strategy: :cluster, position:)
21
21
  end
@@ -115,21 +115,23 @@ module Html2rss
115
115
  groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
116
116
  end
117
117
 
118
- # rubocop:disable-next Metrics/MethodLength
118
+ # A group is a container of +nodes_b+ when one of its nodes strictly encloses two or more of them.
119
119
  def container_of?(nodes_a, nodes_b)
120
120
  return false unless @layout_tags.include?(nodes_b.first.name)
121
121
 
122
- nodes_a.any? do |node_a|
123
- count = 0
124
- nodes_b.each do |node_b|
125
- next if node_a.equal?(node_b)
122
+ counts = enclosed_counts(nodes_b)
123
+ nodes_a.any? { |node_a| counts[node_a] > 1 }
124
+ end
126
125
 
127
- if @index.descendant_of?(node_b, node_a)
128
- count += 1
129
- break if count > 1
130
- end
126
+ # Number of +nodes+ strictly beneath each ancestor, tallied once per group instead of per pair.
127
+ def enclosed_counts(nodes)
128
+ @enclosed_counts ||= {}.compare_by_identity
129
+ @enclosed_counts[nodes] ||= nodes.each_with_object(Hash.new(0).compare_by_identity) do |node, tally|
130
+ ancestor = @index.parent_of(node)
131
+ while ancestor
132
+ tally[ancestor] += 1
133
+ ancestor = @index.parent_of(ancestor)
131
134
  end
132
- count > 1
133
135
  end
134
136
  end
135
137
 
@@ -33,9 +33,10 @@ module Html2rss
33
33
  index = segmenter.index
34
34
  main = main_landmark(index)
35
35
  groups = {}.compare_by_identity
36
+ link_counts = relevant_link_counts(segmenter)
36
37
 
37
38
  index.each_node.select { |node| card_anchor?(segmenter, index, main, node) }.each do |node|
38
- card = parent_until_boundary(segmenter, node)
39
+ card = parent_until_boundary(segmenter, node, link_counts)
39
40
  (groups[index.parent_of(card)] ||= []) << [card, node]
40
41
  end
41
42
  groups
@@ -69,9 +70,8 @@ module Html2rss
69
70
  BOUNDARY_TAGS = Set[:body, :html].freeze
70
71
  private_constant :BOUNDARY_TAGS
71
72
 
72
- def self.parent_until_boundary(segmenter, node)
73
+ def self.parent_until_boundary(segmenter, node, link_counts)
73
74
  index = segmenter.index
74
- link_counts = Hash.new { |hash, curr| hash[curr] = count_relevant_links(segmenter, curr) }
75
75
 
76
76
  index.parent_until(node, lambda { |curr|
77
77
  return true if BOUNDARY_TAGS.include?(curr.name)
@@ -81,11 +81,19 @@ module Html2rss
81
81
  end
82
82
  private_class_method :parent_until_boundary
83
83
 
84
+ # Node-keyed counts shared across every anchor of one run, so shared ancestors are counted once.
85
+ # Keyed by identity: SST nodes are Data, whose structural +#hash+ walks the whole subtree.
86
+ def self.relevant_link_counts(segmenter)
87
+ relevant = Hash.new { |hash, node| hash[node] = relevant_anchor?(segmenter, node) }.compare_by_identity
88
+ Hash.new { |hash, node| hash[node] = count_relevant_links(node, relevant) }.compare_by_identity
89
+ end
90
+ private_class_method :relevant_link_counts
91
+
84
92
  # Walk nodes are relevant anchors or their non-link ancestors, so a link counts as one.
85
- def self.count_relevant_links(segmenter, node)
93
+ def self.count_relevant_links(node, relevant)
86
94
  return 1 if node.link?
87
95
 
88
- node.count_descendants { |child| child.link? && relevant_anchor?(segmenter, child) }
96
+ node.count_descendants { |child| child.link? && relevant[child] }
89
97
  end
90
98
  private_class_method :count_relevant_links
91
99
  end
@@ -4,8 +4,9 @@ module Html2rss
4
4
  class AutoSource
5
5
  class Segmenter
6
6
  ##
7
- # Selects the strongest content-like primary link inside a container
8
- # (port of Discovery::SemanticAnchorCandidates).
7
+ # Selects the strongest content-like primary link of a container
8
+ # (port of Discovery::SemanticAnchorCandidates). A card without a qualifying link of its own
9
+ # falls back to the anchor that wraps the whole card.
9
10
  class PrimaryLink
10
11
  # @param segmenter [Segmenter]
11
12
  def initialize(segmenter)
@@ -32,11 +33,28 @@ module Html2rss
32
33
  winners[dest] = facts if current.nil? || facts[:score] > current[:score]
33
34
  end
34
35
 
35
- winners.values.max_by { |f| f[:score] }&.fetch(:anchor)
36
+ winners.values.max_by { |f| f[:score] }&.fetch(:anchor) || wrapping_link(container)
36
37
  end
37
38
 
38
39
  private
39
40
 
41
+ # The nearest ancestor link when it wraps a whole card.
42
+ def wrapping_link(container)
43
+ link = @index.parent_until(@index.parent_of(container), :link?.to_proc)
44
+ return unless link && !@index.ignored_chrome?(link) && card_link?(link)
45
+
46
+ link if candidate_facts(link, container)
47
+ end
48
+
49
+ # A card link holds the card's heading and no other link. A link holding other links is a
50
+ # parser-recovered unclosed anchor spanning a region; one without a heading wraps a fragment
51
+ # of a story (such as one live-blog entry), not a card.
52
+ def card_link?(link)
53
+ (@card_links ||= {}.compare_by_identity).fetch(link) do
54
+ @card_links[link] = link.find(&:heading?) && link.find { |node| node.link? && !node.equal?(link) }.nil?
55
+ end
56
+ end
57
+
40
58
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
41
59
  def candidate_facts(anchor, container)
42
60
  destination = @link_resolver.destination_facts(anchor)
@@ -45,8 +63,8 @@ module Html2rss
45
63
  text = anchor.visible_text.to_s.strip
46
64
  heading = first_heading(container)
47
65
  heading_text = heading ? heading.visible_text.to_s.strip : ''
48
- heading_anchor = heading_anchor?(anchor, heading)
49
66
  meaningful = text.match?(/\p{Alnum}/)
67
+ heading_anchor = heading_anchor?(anchor, heading) || (!meaningful && overlay_link?(anchor, heading))
50
68
  content_like = destination.content_path
51
69
  heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
52
70
 
@@ -73,6 +91,13 @@ module Html2rss
73
91
 
74
92
  anchor.equal?(heading) || @index.descendant_of?(anchor, heading)
75
93
  end
94
+
95
+ # An empty overlay link beside the heading (or a wrapper of it) stands in for the heading's link.
96
+ def overlay_link?(anchor, heading)
97
+ return false unless heading&.visible_text.to_s.match?(/\p{Alnum}/)
98
+
99
+ @index.descendant_of?(heading, @index.parent_of(anchor))
100
+ end
76
101
  end
77
102
  end
78
103
  end
@@ -13,16 +13,27 @@ module Html2rss
13
13
  # @param segmenter [Segmenter]
14
14
  # @return [Array<Segment>]
15
15
  def self.call(segmenter)
16
- containers = collect_containers(segmenter.index)
17
- primary = PrimaryLink.new(segmenter)
16
+ picks = linked_containers(segmenter)
17
+ # A link wrapping its container is a whole card: it roots one segment, not one per div inside
18
+ # it, and none when a container already selected that link from inside.
19
+ claimed = picks.filter_map { |_, link, inside| link if inside }.to_set.compare_by_identity
18
20
 
19
- containers.each_with_index.filter_map do |container, position|
20
- link = primary.select(container)
21
- next unless link || segmenter.permit_unanchored
21
+ picks.each_with_index.filter_map do |(container, link, inside), position|
22
+ root = inside ? container : (link if claimed.add?(link))
23
+ Segment.build(root_node: root, primary_link: link, strategy: :semantic, position:) if root
24
+ end
25
+ end
22
26
 
23
- Segment.build(root_node: container, primary_link: link, strategy: :semantic, position:)
27
+ # @return [Array<Array(SST::Node, SST::Node, Boolean)>] container, its primary link, link inside it
28
+ def self.linked_containers(segmenter)
29
+ index = segmenter.index
30
+ primary = PrimaryLink.new(segmenter)
31
+ collect_containers(index).filter_map do |container|
32
+ link = primary.select(container)
33
+ [container, link, index.descendant_of?(link, container)] if link
24
34
  end
25
35
  end
36
+ private_class_method :linked_containers
26
37
 
27
38
  def self.collect_containers(index)
28
39
  non_leaf = mark_non_leaf_candidates(index.root)
@@ -14,11 +14,10 @@ module Html2rss
14
14
  # @param base_url [String, Html2rss::Url]
15
15
  # @param strategy [Symbol]
16
16
  # @param opts [Hash] segmentation options
17
- # @option opts [Boolean] :permit_unanchored keep containers without a primary link
18
17
  # @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
19
18
  # @option opts [Integer] :use_top_selectors list strategy selector budget
20
19
  # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
21
- # @return [Array<Segment>]
20
+ # @return [Array<Segment>] linked segments only
22
21
  # rubocop:disable-next Style/ArgumentsForwarding -- keep named opts for YARD @option
23
22
  def self.call(document, base_url:, strategy:, **opts)
24
23
  new(document, base_url:, strategy:, **opts).call
@@ -28,18 +27,15 @@ module Html2rss
28
27
  # @param base_url [String, Html2rss::Url]
29
28
  # @param strategy [Symbol]
30
29
  # @param opts [Hash] segmentation options
31
- # @option opts [Boolean] :permit_unanchored keep containers without a primary link
32
30
  # @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
33
31
  # @option opts [Integer] :use_top_selectors list strategy selector budget
34
32
  # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
35
33
  def initialize(document, base_url:, strategy:, **opts)
36
- raise ArgumentError, 'document must be SST::Document' unless document.is_a?(SST::Document)
37
- raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
34
+ raise Segment::InvalidError, 'document must be SST::Document' unless document.is_a?(SST::Document)
38
35
 
39
36
  @document = document
40
37
  @base_url = base_url
41
- @strategy = strategy
42
- @permit_unanchored = opts.fetch(:permit_unanchored, false)
38
+ @strategy = Segment.strategy!(strategy)
43
39
  @minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
44
40
  @use_top_selectors = opts.fetch(:use_top_selectors, 5)
45
41
  @link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
@@ -55,7 +51,7 @@ module Html2rss
55
51
  end
56
52
  end
57
53
 
58
- attr_reader :document, :base_url, :permit_unanchored, :minimum_selector_frequency,
54
+ attr_reader :document, :base_url, :minimum_selector_frequency,
59
55
  :use_top_selectors, :link_resolver, :noise_policy
60
56
 
61
57
  # @return [SST::Index]