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
@@ -44,7 +44,7 @@ module Html2rss
44
44
  # @param _context [Selectors::StepEnv] post-processor context
45
45
  # @return [void]
46
46
  def self.validate_args!(value, _context)
47
- raise ArgumentError, 'The `value` option is missing or empty.' if value.to_s.empty?
47
+ raise MissingOption, 'The `value` option is missing or empty.' if value.to_s.empty?
48
48
  end
49
49
 
50
50
  ##
@@ -17,12 +17,14 @@ module Html2rss
17
17
  # - `referrer-policy='no-referrer'` to <img> tags
18
18
  # - wraps all <img> tags, whose direct parent is not an <a>, into an <a>
19
19
  # linking to the <img>'s `src`.
20
+ # - keeps <audio>, <video> and <source> with an absolute http(s) `src`; a player
21
+ # without a playable `src` (its own or a <source>'s) is removed.
20
22
  #
21
23
  # Imagine this HTML structure:
22
24
  #
23
25
  # <section>
24
26
  # Lorem <b>ipsum</b> dolor...
25
- # <iframe src="https://evil.corp/miner"></iframe>
27
+ # <iframe src="https://evil.example/miner"></iframe>
26
28
  # <script>alert();</script>
27
29
  # </section>
28
30
  #
@@ -93,10 +95,20 @@ module Html2rss
93
95
  'audio' => {
94
96
  'referrerpolicy' => 'no-referrer',
95
97
  'crossorigin' => 'anonymous',
96
- 'preload' => 'none'
98
+ 'preload' => 'none',
99
+ 'controls' => 'true'
97
100
  }
98
101
  }.freeze
99
102
 
103
+ # Media elements kept in descriptions and the attributes they keep besides the html2rss defaults
104
+ # ({TAG_ATTRIBUTES}); {HtmlTransformers::KeepPlayableMedia} keeps +src+ only as an absolute
105
+ # +http(s)+ URL and drops a player left without one.
106
+ MEDIA_ATTRIBUTES = {
107
+ 'audio' => %w[src],
108
+ 'video' => %w[src],
109
+ 'source' => %w[src type]
110
+ }.freeze
111
+
100
112
  # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
101
113
  def self.schema_export = SchemaExport.for_post_processor(name: :sanitize_html, klass: self)
102
114
 
@@ -145,24 +157,25 @@ module Html2rss
145
157
  ##
146
158
  # @param base_url [String, Html2rss::Url]
147
159
  # @return [Hash] the memoized sanitize configuration
148
- # rubocop:disable-next Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
160
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, ThreadSafety/ClassInstanceVariable
149
161
  def self.sanitize_config(base_url)
150
162
  @sanitize_configs ||= {}
151
163
  @sanitize_configs[base_url] ||= begin
152
164
  config = Sanitize::Config.merge(
153
165
  Sanitize::Config::RELAXED,
154
- attributes: { all: %w[dir lang alt title translate] },
166
+ attributes: { all: %w[dir lang alt title translate], **MEDIA_ATTRIBUTES },
155
167
  add_attributes: TAG_ATTRIBUTES,
156
168
  transformers: [
157
169
  lambda { |env|
158
170
  HtmlTransformers::TransformUrlsToAbsoluteOnes.new(base_url).call(**env)
159
171
  },
172
+ ->(env) { HtmlTransformers::KeepPlayableMedia.new(base_url).call(**env) },
160
173
  ->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
161
174
  ]
162
175
  )
163
176
  config[:elements].delete('style')
164
177
  config.delete(:css)
165
- config[:elements].push('audio', 'video', 'source')
178
+ config[:elements].push(*MEDIA_ATTRIBUTES.keys)
166
179
  config.freeze
167
180
  end
168
181
  end
@@ -70,7 +70,7 @@ module Html2rss
70
70
  return (start..) unless step_config.key?(:end)
71
71
 
72
72
  finish = step_config[:end]
73
- raise ArgumentError, 'The `start` value must be unequal to the `end` value.' if start == finish
73
+ raise InvalidOption, 'The `start` value must be unequal to the `end` value.' if start == finish
74
74
 
75
75
  (start..finish)
76
76
  end
@@ -35,6 +35,10 @@ module Html2rss
35
35
  # Config selector keys that map onto a different {Article} attribute.
36
36
  # +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
37
37
  SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
38
+ # Admission-drop reason tallied for selector items that carry no URL (same shape as
39
+ # {AutoSource#admission_drops}).
40
+ MISSING_URL_REASON = 'missing_url'
41
+
38
42
  # Selector keys that may be copied onto an Article (PROVIDED_KEYS + mapped aliases).
39
43
  SELECTABLE_SELECTOR_KEYS = Set[*(Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys)].freeze
40
44
  # ArticleExtractor keys that enhance can fill. Skip the extractor when all are already present.
@@ -56,6 +60,7 @@ module Html2rss
56
60
  @time_zone = time_zone
57
61
  @rss_item_attributes = @selectors.keys.select { SELECTABLE_SELECTOR_KEYS.include?(_1) }
58
62
  @parsed_body = nil
63
+ @unlinked_count = 0
59
64
  @compiled_fields = compile_selector_fields
60
65
  @compiled_item_fields = @rss_item_attributes.filter_map { @compiled_fields[_1] }
61
66
  end
@@ -70,39 +75,52 @@ module Html2rss
70
75
  end
71
76
 
72
77
  ##
73
- # Iterates over each scraped article.
78
+ # Drop tallies for the last iteration: items skipped for having no URL, as {AutoSource#admission_drops}.
79
+ #
80
+ # @return [Hash{String => Integer}] empty when every item was linked
81
+ def admission_drops
82
+ @unlinked_count.positive? ? { MISSING_URL_REASON => @unlinked_count }.freeze : {}.freeze
83
+ end
84
+
85
+ ##
86
+ # Iterates over each scraped article. An item without a URL is skipped and tallied in {#admission_drops},
87
+ # as in auto-source ({Html2rss::Article#linked?}).
74
88
  #
75
89
  # @yield [article] Gives each article as an Html2rss::Article.
76
90
  # @return [Enumerator] An enumerator if no block is given.
77
91
  def each(&)
78
- return enum_for(:each) unless block_given?
92
+ return enum_for unless block_given?
79
93
 
80
94
  enhance = enhance?
95
+ @unlinked_count = 0
81
96
 
82
97
  parsed_body.css(items_selector).each do |item|
83
98
  article_hash = extract_article(item)
84
99
 
85
100
  enhance_article_hash(article_hash, item) if enhance
86
101
 
87
- yield Html2rss::Article.new(**article_hash, scraper: self.class)
102
+ article = build_article(article_hash)
103
+ article.linked? ? yield(article) : @unlinked_count += 1
88
104
  end
89
105
  end
90
106
 
91
107
  ##
92
108
  # Yields +baseline+, +enhanced+, and the item node per list entry for enhance diagnostics.
109
+ #
110
+ # @param linked_only [Boolean] skip items {#each} would drop for having no URL
93
111
  # Consumed by {Test::EnhanceAudit} so the extraction loop has one owner.
94
112
  #
95
113
  # @yieldparam baseline [Hash]
96
114
  # @yieldparam enhanced [Hash]
97
115
  # @yieldparam item [Nokogiri::XML::Element]
98
116
  # @return [Enumerator] if no block is given
99
- def each_enhance_pair
100
- return enum_for(:each_enhance_pair) unless block_given?
117
+ def each_enhance_pair(linked_only: false)
118
+ return enum_for(:each_enhance_pair, linked_only:) unless block_given?
101
119
 
102
120
  parsed_body.css(items_selector).each do |item|
103
121
  baseline = extract_article(item)
104
122
  enhanced = enhance_article_hash(baseline.dup, item)
105
- yield baseline, enhanced, item
123
+ yield baseline, enhanced, item unless linked_only && !build_article(enhanced).linked?
106
124
  end
107
125
  end
108
126
 
@@ -168,6 +186,12 @@ module Html2rss
168
186
  text.empty? || text == DEFAULT_ITEMS_SELECTOR
169
187
  end
170
188
 
189
+ ##
190
+ # Whether +selectors.items.enhance+ is on: each matched item node fills fields that explicit
191
+ # selectors left empty (see {ENHANCEABLE_KEYS}) via {Html::ArticleExtractor}, using only the
192
+ # item's own markup and making no extra requests.
193
+ #
194
+ # @return [Boolean]
171
195
  def enhance? = !!@selectors.dig(ITEMS_SELECTOR_KEY, :enhance)
172
196
 
173
197
  def parsed_body
@@ -183,6 +207,8 @@ module Html2rss
183
207
  end
184
208
  end
185
209
 
210
+ def build_article(article_hash) = Article.new(**article_hash, scraper: self.class)
211
+
186
212
  def extract_article(item)
187
213
  scope = item_env_for(item, @url)
188
214
  hash = @compiled_item_fields.each_with_object({}) do |field, h|
@@ -190,13 +216,30 @@ module Html2rss
190
216
  next if value.nil?
191
217
 
192
218
  article_key = field.article_key
193
- h[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
219
+ h[article_key] = article_value(article_key, value, scope)
194
220
  end
195
221
 
196
222
  hash[:url] ||= default_item_url(item) if anchor_element?(item)
197
223
  hash
198
224
  end
199
225
 
226
+ def article_value(article_key, value, scope)
227
+ case article_key
228
+ when :enclosures then wrap_enclosure_value(value)
229
+ when :url then resolve_url_value(value, scope.base_url)
230
+ else value
231
+ end
232
+ end
233
+
234
+ # A +url+ selector value that is a single whitespace-free string token (a +static+ value or a post-processor
235
+ # result) resolves against the page URL like an +href+; a value that is no http(s) reference stays as given.
236
+ # Text with surrounding words is left to +Article#url+'s sanitizer, which extracts the embedded URL.
237
+ def resolve_url_value(value, base_url)
238
+ return value unless value.is_a?(String) && value.match?(/\A\S+\z/)
239
+
240
+ Url.resolve_web(value, base_url) || value
241
+ end
242
+
200
243
  def enhance_article_hash(article_hash, article_tag) # rubocop:disable Metrics/MethodLength -- merge extracted keys
201
244
  return article_hash if enhanceable_fields_present?(article_hash)
202
245
 
@@ -205,7 +248,6 @@ module Html2rss
205
248
  article_tag,
206
249
  base_url: @url,
207
250
  selected_anchor:,
208
- fallback_anchorless: true,
209
251
  time_zone: @time_zone
210
252
  )
211
253
  return article_hash unless extracted
@@ -392,12 +434,11 @@ module Html2rss
392
434
  Extractors.element(item, config[:selector])
393
435
  end
394
436
 
395
- # @return [Hash, nil] enclosure details, or nil when the selector yields nothing.
437
+ # @return [Hash, nil] enclosure details, or nil when the selector yields nothing or no http(s) URL
438
+ # ({Url.resolve_web})
396
439
  def enclosure(scope:, config:)
397
440
  selected = select_regular(:enclosure, scope:, config:)
398
- return if selected.nil? || selected.to_s.strip.empty?
399
-
400
- url = Url.from_relative(selected, scope.base_url)
441
+ return unless (url = Url.resolve_web(selected, scope.base_url))
401
442
 
402
443
  { url:, type: config[:content_type] }
403
444
  end
@@ -65,7 +65,7 @@ module Html2rss
65
65
  case class_names
66
66
  when nil then AttrDefaults::EMPTY_CLASS_NAMES
67
67
  when Array then class_names.map { |token| token.to_s.freeze }.freeze
68
- else raise ArgumentError, "class_names must be an Array, got #{class_names.class}"
68
+ else raise InvalidError, "class_names must be an Array, got #{class_names.class}"
69
69
  end
70
70
  end
71
71
 
@@ -73,7 +73,7 @@ module Html2rss
73
73
  case raw
74
74
  when nil then AttrDefaults::EMPTY_RAW
75
75
  when Hash then raw.transform_values { |v| v.to_s.freeze }.freeze
76
- else raise ArgumentError, "raw must be a Hash, got #{raw.class}"
76
+ else raise InvalidError, "raw must be a Hash, got #{raw.class}"
77
77
  end
78
78
  end
79
79
  end
@@ -13,10 +13,10 @@ module Html2rss
13
13
  # @param degraded [Boolean] true when MAX_NODES forced semantic-tag-only mode
14
14
  # @param node_count [Integer]
15
15
  # @return [Document]
16
- # @raise [ArgumentError] when root/index missing
16
+ # @raise [InvalidError] when root/index missing
17
17
  def self.build(root:, index:, degraded: false, node_count: 0)
18
- raise ArgumentError, 'root is required' unless root.is_a?(Node)
19
- raise ArgumentError, 'index is required' unless index.is_a?(Index)
18
+ raise InvalidError, 'root is required' unless root.is_a?(Node)
19
+ raise InvalidError, 'index is required' unless index.is_a?(Index)
20
20
 
21
21
  new(root:, index:, degraded: !!degraded, node_count: Integer(node_count))
22
22
  end
@@ -14,18 +14,32 @@ module Html2rss
14
14
  # @return [Index, nil]
15
15
  def self.for_node(node) = NODE_BINDINGS[node]
16
16
 
17
+ ##
18
+ # Whether +node+ is +ancestor+ or inside it: parent pointers when +node+ is indexed, else a
19
+ # scan of +ancestor+'s subtree.
20
+ #
21
+ # @param node [Node]
22
+ # @param ancestor [Node]
23
+ # @return [Boolean]
24
+ def self.descendant_or_self?(node, ancestor)
25
+ return true if node.equal?(ancestor)
26
+
27
+ index = for_node(node)
28
+ index ? index.descendant_of?(node, ancestor) : !ancestor.find { _1.equal?(node) }.nil?
29
+ end
30
+
17
31
  ##
18
32
  # @param root [Node]
19
33
  # @param parents [Hash{Node => Node, nil}]
20
34
  # @param depths [Hash{Node => Integer}]
21
- # @param ignored_chrome [Hash{Node => Boolean}]
22
- def initialize(root:, parents:, depths:, ignored_chrome:)
35
+ # @param scopes [Hash{Node => Symbol}] {Tags.chrome_scope} of each node
36
+ def initialize(root:, parents:, depths:, scopes:)
23
37
  @root = root
24
38
  @parents = parents
25
39
  @depths = depths
26
- @ignored_chrome = ignored_chrome
40
+ @scopes = scopes
27
41
  @visible_text = {}.compare_by_identity
28
- @word_count = {}.compare_by_identity
42
+ @run_count = {}.compare_by_identity
29
43
  bind_tree(root)
30
44
  end
31
45
 
@@ -44,7 +58,14 @@ module Html2rss
44
58
  ##
45
59
  # @param node [Node]
46
60
  # @return [Boolean]
47
- def ignored_chrome?(node) = @ignored_chrome.fetch(node, false)
61
+ def ignored_chrome?(node) = @scopes[node] == :chrome
62
+
63
+ ##
64
+ # Inside a utility landmark that is not chrome (aside, in-content nav, sectioned footer).
65
+ #
66
+ # @param node [Node]
67
+ # @return [Boolean]
68
+ def peripheral?(node) = @scopes[node] == :peripheral
48
69
 
49
70
  ##
50
71
  # Memoized default visible text (separator space, no excludes).
@@ -59,13 +80,11 @@ module Html2rss
59
80
 
60
81
  ##
61
82
  # @param node [Node]
62
- # @return [Integer]
63
- def memo_word_count(node)
64
- return @word_count[node] if @word_count.key?(node)
83
+ # @return [Integer] memoized letter/digit runs of the default visible text ({WordCount.runs})
84
+ def memo_run_count(node)
85
+ return @run_count[node] if @run_count.key?(node)
65
86
 
66
- count = 0
67
- memo_visible_text(node).to_s.scan(/\p{Alnum}+/) { count += 1 }
68
- @word_count[node] = count
87
+ @run_count[node] = WordCount.runs(memo_visible_text(node))
69
88
  end
70
89
 
71
90
  ##
@@ -16,7 +16,7 @@ module Html2rss
16
16
  # @param children [Array<Node>, nil]
17
17
  # @param tag_path [String, nil] index-free ancestry path for list clustering
18
18
  # @return [Node]
19
- # @raise [ArgumentError] on invalid name/attrs/children
19
+ # @raise [InvalidError] on invalid name/attrs/children
20
20
  def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil, tails: nil) # rubocop:disable Metrics/ParameterLists
21
21
  attrs_value = coerce_attrs(attrs)
22
22
  kids = coerce_children(children)
@@ -32,7 +32,7 @@ module Html2rss
32
32
  end
33
33
 
34
34
  def self.coerce_name(name)
35
- raise ArgumentError, 'name is required' if name.nil? || name.to_s.strip.empty?
35
+ raise InvalidError, 'name is required' if name.nil? || name.to_s.strip.empty?
36
36
 
37
37
  name.to_sym
38
38
  end
@@ -40,7 +40,7 @@ module Html2rss
40
40
 
41
41
  def self.coerce_attrs(attrs)
42
42
  attrs_value = attrs.nil? ? Attrs.empty : attrs
43
- raise ArgumentError, 'attrs must be SST::Attrs' unless attrs_value.is_a?(Attrs)
43
+ raise InvalidError, 'attrs must be SST::Attrs' unless attrs_value.is_a?(Attrs)
44
44
 
45
45
  attrs_value
46
46
  end
@@ -48,7 +48,7 @@ module Html2rss
48
48
 
49
49
  def self.coerce_children(children)
50
50
  kids = Array(children).freeze
51
- raise ArgumentError, 'children must be SST::Node instances' unless kids.all?(Node)
51
+ raise InvalidError, 'children must be SST::Node instances' unless kids.all?(Node)
52
52
 
53
53
  kids
54
54
  end
@@ -74,11 +74,7 @@ module Html2rss
74
74
 
75
75
  ##
76
76
  # @return [Boolean]
77
- def utility_landmark? = Tags::UTILITY_LANDMARK_NAMES.include?(name)
78
-
79
- ##
80
- # @return [Boolean]
81
- def ignored_container_name? = Tags::IGNORED_CONTAINER_NAMES.include?(name)
77
+ def utility_landmark? = Tags.utility_landmark?(name.name, attrs.raw['role'])
82
78
 
83
79
  ##
84
80
  # @return [Boolean]
@@ -123,15 +119,23 @@ module Html2rss
123
119
  end
124
120
 
125
121
  ##
126
- # @return [Array<Node>]
122
+ # @yieldparam node [Node]
123
+ # @return [Array<Node>] self and descendants (depth-first) for which the block is truthy
127
124
  def find_all(&)
128
- each_node.select(&)
125
+ collect_matching([], &)
129
126
  end
130
127
 
131
128
  ##
132
- # @return [Node, nil]
133
- def find(&)
134
- each_node.find(&)
129
+ # @yieldparam node [Node]
130
+ # @return [Node, nil] first self-or-descendant (depth-first) for which the block is truthy
131
+ def find(&block)
132
+ return self if yield(self)
133
+
134
+ children.each do |child|
135
+ hit = child.find(&block)
136
+ return hit if hit
137
+ end
138
+ nil
135
139
  end
136
140
 
137
141
  ##
@@ -149,23 +153,26 @@ module Html2rss
149
153
  end
150
154
 
151
155
  ##
152
- # @return [Integer] alphanumeric word count of visible text
153
- def word_count
154
- if (index = Index.for_node(self))
155
- return index.memo_word_count(self)
156
- end
157
-
158
- count = 0
159
- visible_text.to_s.scan(/\p{Alnum}+/) { count += 1 }
160
- count
156
+ # @return [Integer] letter/digit runs of visible text ({WordCount.runs})
157
+ def run_count
158
+ index = Index.for_node(self)
159
+ index ? index.memo_run_count(self) : WordCount.runs(visible_text)
161
160
  end
162
161
 
163
162
  ##
164
- # @return [Float] words per descendant link
163
+ # @return [Float] letter/digit runs per descendant link
165
164
  def text_density
166
- words = word_count
165
+ runs = run_count
167
166
  links = count_descendants(&:link?)
168
- links.zero? ? words.to_f : words.to_f / links
167
+ links.zero? ? runs.to_f : runs.to_f / links
168
+ end
169
+
170
+ protected
171
+
172
+ def collect_matching(found, &block)
173
+ found << self if yield(self)
174
+ children.each { |child| child.collect_matching(found, &block) }
175
+ found
169
176
  end
170
177
  end
171
178
  end
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # Hard ceiling for SST node allocations; beyond this we degrade.
12
12
  MAX_NODES = 5_000
13
13
 
14
- # Tags stripped entirely from the SST.
14
+ # Tags stripped entirely from the SST; a +noscript+ keeps only its file links (see {#child_nodes}).
15
15
  STRIPPED_TAGS = Set['script', 'style', 'noscript', 'iframe', 'svg', 'template'].freeze
16
16
 
17
17
  # Kept when MAX_NODES forces semantic-tag-only degrade.
@@ -29,9 +29,12 @@ module Html2rss
29
29
  href |
30
30
  src |
31
31
  srcset |
32
+ poster\z |
32
33
  style |
33
34
  datetime |
34
35
  itemprop |
36
+ rel\z |
37
+ hidden\z |
35
38
  role |
36
39
  type |
37
40
  category |
@@ -54,17 +57,14 @@ module Html2rss
54
57
  # Typed Attrs fields excluded from the leftover raw hash.
55
58
  TYPED_ATTR_NAMES = Set['href', 'src', 'id', 'class', 'datetime', 'itemprop', 'style', 'srcset', 'type'].freeze
56
59
 
57
- # String form of Tags::IGNORED_CONTAINER_NAMES for chrome checks without to_sym.
58
- IGNORED_CONTAINER_TAGS = Tags::IGNORED_CONTAINER_NAMES.to_set(&:to_s).freeze
59
-
60
60
  class << self
61
61
  ##
62
62
  # @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
63
63
  # HTML string or already-parsed node (String is parsed once here)
64
64
  # @return [Document, nil] +nil+ when no SST nodes survive normalization
65
- # @raise [InvalidOption] when input is nil or unsupported
65
+ # @raise [InvalidError] when input is nil or unsupported
66
66
  def call(input)
67
- raise InvalidOption, 'input is required' if input.nil?
67
+ raise InvalidError, 'input is required' if input.nil?
68
68
 
69
69
  new(coerce(input)).call
70
70
  end
@@ -78,7 +78,7 @@ module Html2rss
78
78
  when String then Nokogiri::HTML(input)
79
79
  when Nokogiri::XML::Node then input
80
80
  else
81
- raise InvalidOption, "expected String or Nokogiri::XML::Node, got #{input.class}"
81
+ raise InvalidError, "expected String or Nokogiri::XML::Node, got #{input.class}"
82
82
  end
83
83
  end
84
84
  end
@@ -90,17 +90,17 @@ module Html2rss
90
90
  @degraded = false
91
91
  @parents = {}.compare_by_identity
92
92
  @depths = {}.compare_by_identity
93
- @ignored_chrome = {}.compare_by_identity
93
+ @scopes = {}.compare_by_identity
94
94
  end
95
95
 
96
96
  ##
97
97
  # @return [Document, nil] +nil+ when no SST nodes survive normalization
98
98
  def call
99
99
  root_nk = resolve_root_nk
100
- root = normalize_element(root_nk, depth: 0, path: '', chrome: false)
100
+ root = normalize_element(root_nk, depth: 0, path: '', scope: :page)
101
101
  return unless root
102
102
 
103
- index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
103
+ index = Index.new(root:, parents: @parents, depths: @depths, scopes: @scopes)
104
104
  Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
105
105
  end
106
106
 
@@ -146,12 +146,12 @@ module Html2rss
146
146
  node.element? && !STRIPPED_TAGS.include?(Html2rss::Html::Probe.tag(node))
147
147
  end
148
148
 
149
- # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
150
- def normalize_element(nk_node, depth:, path:, chrome:)
149
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
150
+ def normalize_element(nk_node, depth:, path:, scope:)
151
151
  return unless nk_node.element?
152
152
 
153
153
  tag = Html::Probe.tag(nk_node)
154
- return if STRIPPED_TAGS.include?(tag)
154
+ return unless (nk_children = child_nodes(nk_node, tag))
155
155
 
156
156
  if @node_count >= MAX_NODES && !@degraded
157
157
  @degraded = true
@@ -167,18 +167,30 @@ module Html2rss
167
167
  @node_count += 1
168
168
  tag_path = "#{path}/#{tag}"
169
169
  attrs = extract_attrs(nk_node)
170
- chrome_here = chrome || IGNORED_CONTAINER_TAGS.include?(tag)
170
+ scope_here = Tags.chrome_scope(nk_node, scope)
171
171
 
172
- children, lead, tails = normalize_children(nk_node, depth:, path: tag_path, chrome: chrome_here)
172
+ children, lead, tails = normalize_children(nk_children, depth:, path: tag_path, scope: scope_here)
173
173
 
174
174
  node = Node.build(name: tag, attrs:, own_text: [lead, *tails].join, children:, tag_path:, tails:)
175
175
  @depths[node] = depth
176
- @ignored_chrome[node] = chrome_here
176
+ @scopes[node] = scope_here
177
177
  children.each { |child| @parents[child] = node }
178
178
 
179
179
  node
180
180
  end
181
181
 
182
+ # The Nokogiri children to normalize, or nil for a stripped tag. A +noscript+ is kept only for the
183
+ # file links inside it ({Html::ArticleRules::Enclosure.file_link?}), as its only children, so a
184
+ # card's no-script download link still names its enclosure; its text stays invisible
185
+ # ({Tags::INVISIBLE_NAMES}) and a +noscript+ without one is stripped.
186
+ def child_nodes(nk_node, tag)
187
+ return nk_node.children unless STRIPPED_TAGS.include?(tag)
188
+ return unless tag == 'noscript'
189
+
190
+ links = nk_node.xpath('.//a[@href]').select { Html::ArticleRules::Enclosure.file_link?(_1['href']) }
191
+ links unless links.empty?
192
+ end
193
+
182
194
  def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
183
195
  Attrs.build(
184
196
  href: nk_node['href'],
@@ -206,13 +218,13 @@ module Html2rss
206
218
 
207
219
  # Walks child nodes once, keeping direct text in the slot of the preceding kept child
208
220
  # (slot 0 is the lead text) so text order survives the element-only tree.
209
- def normalize_children(nk_node, depth:, path:, chrome:)
221
+ def normalize_children(nk_children, depth:, path:, scope:)
210
222
  children = []
211
223
  slots = [+'']
212
- nk_node.children.each do |child|
224
+ nk_children.each do |child|
213
225
  next slots.last << child.text if child.text?
214
226
 
215
- node = normalize_element(child, depth: depth + 1, path:, chrome:)
227
+ node = normalize_element(child, depth: depth + 1, path:, scope:)
216
228
  (children << node) && (slots << +'') if node
217
229
  end
218
230
  [children, slots.first, slots.drop(1)]