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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Edge validation for caller-supplied option values (public API, CLI, MCP).
6
+ # Everything past these checks may trust its types.
7
+ module Option
8
+ class << self
9
+ ##
10
+ # @param name [Symbol] option name used in the error message
11
+ # @param value [Object] candidate value
12
+ # @param min [Integer] inclusive lower bound
13
+ # @param max [Integer, nil] inclusive upper bound
14
+ # @param optional [Boolean] whether +nil+ passes through
15
+ # @return [Integer, nil] +value+ when valid
16
+ # @raise [InvalidOption] when +value+ is not an Integer within bounds
17
+ def integer(name, value, min:, max: nil, optional: false)
18
+ return value if value.nil? && optional
19
+ return value if value.is_a?(Integer) && (min..max).cover?(value)
20
+
21
+ raise InvalidOption, "#{name} must be an Integer in #{min}..#{max}, got #{value.inspect}"
22
+ end
23
+
24
+ ##
25
+ # @param value [Symbol, String, nil] strategy plan name (+nil+ means +:auto+)
26
+ # @return [Symbol] the plan name
27
+ # @raise [InvalidOption] when +value+ is not a registered plan
28
+ def strategy(value)
29
+ name = value.nil? ? FeedPipeline::StrategyPlan::AUTO_NAME : value
30
+ return name.to_sym if FeedPipeline::StrategyPlan.valid?(name)
31
+
32
+ accepted = FeedPipeline::StrategyPlan.accepted_names.join(', ')
33
+ raise InvalidOption, "strategy must be one of #{accepted}, got #{value.inspect}"
34
+ end
35
+
36
+ ##
37
+ # @param name [Symbol] option name used in the error message
38
+ # @param value [Object] candidate list
39
+ # @return [Array] +value+ when it is a non-empty Array
40
+ # @raise [InvalidOption] otherwise
41
+ def list(name, value)
42
+ return value if value.is_a?(Array) && value.any?
43
+
44
+ raise InvalidOption, "#{name} must be a non-empty Array, got #{value.inspect}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -10,7 +10,7 @@
10
10
  | `final_url` | URL after redirects (may differ from requested) |
11
11
  | `status` | HTTP status of the **final** response |
12
12
  | `scheme_downgrade` | `true` when HTTPS entry landed on HTTP |
13
- | `alternate_feeds` | `rel=alternate` RSS/Atom links found in HTML |
13
+ | `alternate_feeds` | `rel=alternate` RSS/Atom/RDF links found in HTML |
14
14
  | `surface_category` | AutoSource surface class (`high_entropy_surface`, `unsupported_surface`, …) |
15
15
  | `articles_count` | Cheap AutoSource extract (limit 10) — diagnostic only, not ship quality |
16
16
  | `strategy` | Concrete transport used (`inspect` maps `auto` → default (HTTPX)) |
@@ -34,14 +34,12 @@ module Html2rss
34
34
  JS_SHELL_MIN_BODY_BYTES = 8_192
35
35
  private_constant :JS_SHELL_MIN_BODY_BYTES
36
36
 
37
- module_function
38
-
39
37
  ##
40
38
  # @param url [String]
41
39
  # @param strategy [String, Symbol]
42
40
  # @param deep [Boolean] when true and strategy is auto, one Botasaurus hop if configured
43
41
  # @return [Report]
44
- def call(url:, strategy: :auto, deep: false)
42
+ def self.call(url:, strategy: :auto, deep: false)
45
43
  probe = PageRecon.probe(url, strategy: resolve_inspect_strategy(strategy, deep:))
46
44
  recon = probe.result
47
45
  response = probe.response
@@ -56,10 +54,12 @@ module Html2rss
56
54
  # @param strategy [Symbol, String]
57
55
  # @param concurrency [Integer]
58
56
  # @return [Array<Report>]
59
- def batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
57
+ def self.batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
60
58
  Batch.map(Array(urls), concurrency:) do |url|
61
59
  call(url:, strategy:)
62
- rescue StandardError => error
60
+ rescue Html2rss::ContractError
61
+ raise
62
+ rescue Html2rss::Error => error
63
63
  error_report(url, error)
64
64
  end
65
65
  end
@@ -67,7 +67,7 @@ module Html2rss
67
67
  ##
68
68
  # @param response [Html2rss::RequestService::Response]
69
69
  # @return [Hash] redacted XHR capture diagnostics (no query strings)
70
- def xhr_capture_info(response)
70
+ def self.xhr_capture_info(response)
71
71
  captured = response.captured_responses
72
72
  {
73
73
  count: captured.size,
@@ -79,7 +79,7 @@ module Html2rss
79
79
  ##
80
80
  # @param entry [Hash] captured response hash
81
81
  # @return [String, nil] scheme+host+path only
82
- def redacted_endpoint(entry)
82
+ def self.redacted_endpoint(entry)
83
83
  raw = entry['url'] || entry[:url]
84
84
  return unless raw
85
85
 
@@ -94,7 +94,7 @@ module Html2rss
94
94
  ##
95
95
  # @param entry [Hash] captured response hash
96
96
  # @return [Boolean]
97
- def xhr_candidate_articles?(entry)
97
+ def self.xhr_candidate_articles?(entry)
98
98
  body = entry['body'] || entry[:body]
99
99
  return false unless body.is_a?(String)
100
100
 
@@ -107,7 +107,7 @@ module Html2rss
107
107
  ##
108
108
  # @param parsed [Object] parsed response body
109
109
  # @return [Array<String>, Hash]
110
- def scraper_info(parsed)
110
+ def self.scraper_info(parsed)
111
111
  return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
112
112
 
113
113
  begin
@@ -158,8 +158,6 @@ module Html2rss
158
158
  def self.discover_segments(sst, url)
159
159
  link_resolver = Scoring::LinkResolver.new(url)
160
160
  AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
161
- rescue StandardError
162
- []
163
161
  end
164
162
 
165
163
  ##
@@ -268,8 +266,6 @@ module Html2rss
268
266
 
269
267
  def sst_document
270
268
  Html2rss::SST::Normalizer.call(response.body)
271
- rescue ArgumentError
272
- nil
273
269
  end
274
270
 
275
271
  def segment_stats(sst, page_url)
@@ -7,10 +7,8 @@ require 'fileutils'
7
7
  module Html2rss
8
8
  ##
9
9
  # Service that runs reconnaissance on a URL to discover redirect chains,
10
- # native RSS/Atom feeds, surface categorization, and emit an actionable verdict.
10
+ # native RSS/Atom/RDF feeds, surface categorization, and emit an actionable verdict.
11
11
  module Recon # rubocop:disable Metrics/ModuleLength
12
- module_function
13
-
14
12
  Verdict = Data.define(:name)
15
13
 
16
14
  ##
@@ -23,7 +21,7 @@ module Html2rss
23
21
  ##
24
22
  # @param name [Symbol, String]
25
23
  # @return [Verdict]
26
- def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown verdict: #{name.inspect}" }
24
+ def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown verdict: #{name.inspect}" }
27
25
 
28
26
  ##
29
27
  # @return [Boolean]
@@ -106,19 +104,21 @@ module Html2rss
106
104
  # @option options [Integer, nil] :max_redirects optional maximum redirects
107
105
  # @option options [Integer, nil] :max_requests optional request budget
108
106
  # @return [Html2rss::Recon::Result]
109
- def call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
107
+ def self.call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
110
108
  url_obj = Url.from_absolute(url)
111
109
 
112
110
  begin
113
111
  probe = PageRecon.probe(url_obj, strategy:, **)
114
- rescue StandardError => error
112
+ rescue Html2rss::ContractError
113
+ raise
114
+ rescue Html2rss::Error => error
115
115
  return error_result(url_obj, error)
116
116
  end
117
117
 
118
118
  cache_html_body(probe.response.body, url_obj, cache_dir, cache_mutex) if cache_dir && probe.response.body
119
119
 
120
120
  recon = probe.result
121
- native_feed, discovery_note = find_native_feed(url_obj, probe.session, probe.response)
121
+ native_feed, discovery_note = find_native_feed(probe.session, probe.response)
122
122
  notes = build_notes(recon, native_feed, probe.response)
123
123
  notes << discovery_note if discovery_note
124
124
  verdict = determine_verdict(recon, native_feed)
@@ -149,7 +149,7 @@ module Html2rss
149
149
  # @option options [Integer, nil] :max_redirects optional maximum redirects
150
150
  # @option options [Integer, nil] :max_requests optional request budget
151
151
  # @return [Array<Html2rss::Recon::Result>]
152
- def batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
152
+ def self.batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
153
153
  url_list = urls.map { |u| u.to_s.strip }.reject(&:empty?)
154
154
  return [] if url_list.empty?
155
155
 
@@ -162,24 +162,20 @@ module Html2rss
162
162
  end
163
163
 
164
164
  ##
165
- # @param url_obj [Html2rss::Url]
166
165
  # @param session [Html2rss::RequestSession]
167
- # @param response [Html2rss::RequestService::Response]
166
+ # @param response [Html2rss::RequestService::Response] the fetched page, where any redirect landed
168
167
  # @return [Array(Html2rss::Url, nil), String, nil)] feed URL and optional discovery error note
169
- def find_native_feed(url_obj, session, response)
170
- feed = Syndication::Discovery.best_feed_url(
171
- page_url: url_obj,
172
- request_session: session,
173
- parsed_body: (response.parsed_body if response.html_response?),
174
- html: response.body
175
- )
168
+ def self.find_native_feed(session, response)
169
+ feed = Syndication::Discovery.native_feed_url(response:, request_session: session)
176
170
  [feed, nil]
177
- rescue StandardError => error
171
+ rescue Html2rss::ContractError
172
+ raise
173
+ rescue Html2rss::Error => error
178
174
  [nil, "discovery_error=#{error.class}: #{error.message}"]
179
175
  end
180
176
  private_class_method :find_native_feed
181
177
 
182
- def build_notes(recon, native_feed, response)
178
+ def self.build_notes(recon, native_feed, response)
183
179
  notes = []
184
180
  notes << "native_rss=#{native_feed}" if native_feed
185
181
  if recon.scheme_downgrade
@@ -192,7 +188,7 @@ module Html2rss
192
188
  end
193
189
  private_class_method :build_notes
194
190
 
195
- def determine_verdict(recon, native_feed)
191
+ def self.determine_verdict(recon, native_feed)
196
192
  return Verdict[:drop] if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
197
193
  return Verdict[:defer] if native_feed
198
194
 
@@ -203,7 +199,7 @@ module Html2rss
203
199
  end
204
200
  private_class_method :determine_verdict
205
201
 
206
- def error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
202
+ def self.error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
207
203
  Result.new(
208
204
  requested_url: url_obj,
209
205
  final_url: url_obj,
@@ -226,14 +222,14 @@ module Html2rss
226
222
  # @param cache_dir [String]
227
223
  # @param cache_mutex [Mutex, nil]
228
224
  # @return [void]
229
- def cache_html_body(body, url_obj, cache_dir, cache_mutex)
225
+ def self.cache_html_body(body, url_obj, cache_dir, cache_mutex)
230
226
  host = url_obj.host.to_s.delete_prefix('www.')
231
227
  host = 'snapshot' if host.empty?
232
228
  digest = Digest::SHA256.hexdigest(url_obj.to_s)[0, 12]
233
229
  file_path = File.join(cache_dir, "#{host}-#{digest}.html")
234
230
  writer = -> { File.write(file_path, body) }
235
231
  cache_mutex ? cache_mutex.synchronize(&writer) : writer.call
236
- rescue StandardError => error
232
+ rescue SystemCallError, IOError => error
237
233
  Log.debug("Recon cache failed: #{error.message}")
238
234
  end
239
235
  private_class_method :cache_html_body
@@ -34,7 +34,8 @@ module Html2rss
34
34
  /interstitial/i
35
35
  ],
36
36
  message: 'Blocked surface detected: DataDome anti-bot challenge page. ' \
37
- 'Target a direct listing URL, or scrape via a session with resolved DataDome cookies.'
37
+ 'Target a direct listing URL. Clearance cookies from a successful Botasaurus scrape ' \
38
+ 'are reused automatically; to supply your own, set request.botasaurus.cookies.'
38
39
  },
39
40
  {
40
41
  key: :vercel_security_checkpoint,
@@ -79,10 +80,10 @@ module Html2rss
79
80
  end
80
81
 
81
82
  def self.interstitial_signature_match?(body, signature)
82
- min_matches = signature.fetch(:min_matches, 1)
83
+ min_matches = signature[:min_matches]
83
84
  matches = 0
84
85
 
85
- signature.fetch(:patterns).each do |pattern|
86
+ signature[:patterns].each do |pattern|
86
87
  matches += 1 if pattern.match?(body)
87
88
  return true if matches >= min_matches
88
89
  end
@@ -91,11 +92,7 @@ module Html2rss
91
92
  end
92
93
  private_class_method :interstitial_signature_match?
93
94
 
94
- def self.normalize_body(body)
95
- body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
96
- rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
97
- body.to_s.force_encoding(Encoding::UTF_8).scrub
98
- end
95
+ def self.normalize_body(body) = body.to_s.scrub
99
96
  private_class_method :normalize_body
100
97
  end
101
98
  end
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # Closed set from OpenAPI ExecutionMode.
12
12
  EXECUTION_MODES = Set['auto', 'request', 'browser'].freeze
13
13
  # Closed set from OpenAPI NavigationMode.
14
- NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass', 'organic_get'].freeze
14
+ NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass'].freeze
15
15
  # Closed set from OpenAPI ErrorCategory.
16
16
  ERROR_CATEGORIES = Set['timeout', 'challenge_block', 'navigation_error', 'metadata_error', 'validation'].freeze
17
17
 
@@ -84,6 +84,15 @@ module Html2rss
84
84
  # @return [String, nil] final URL reported by upstream
85
85
  def final_url = payload['final_url']
86
86
 
87
+ # Session credentials: keep out of logs, transport_meta and feed output.
88
+ # @return [Hash{String => String}] cookies earned for the target host (absent or malformed → {})
89
+ def cookies
90
+ raw = payload['cookies']
91
+ return {} unless raw.is_a?(Hash)
92
+
93
+ raw.select { |name, value| name.is_a?(String) && value.is_a?(String) }
94
+ end
95
+
87
96
  # @return [Hash{String => Object}] diagnostics plus success-only metadata_error (frozen)
88
97
  def transport_meta
89
98
  meta = diagnostics.dup
@@ -258,6 +267,7 @@ module Html2rss
258
267
  # @param url [Html2rss::Url] canonical URL to scrape
259
268
  # @param headers [Hash] request headers from context
260
269
  # @param options [Hash] validated request.botasaurus options
270
+ # @param stored_cookies [Hash{String => String}] cookies replayed from the cookie store
261
271
  # @option options [String] :execution_mode
262
272
  # @option options [String] :navigation_mode
263
273
  # @option options [Integer] :max_retries
@@ -275,18 +285,23 @@ module Html2rss
275
285
  # @option options [String] :lang
276
286
  # @option options [Hash] :cookies
277
287
  # @option options [Hash] :headers
278
- def initialize(url:, headers: {}, options: {})
288
+ def initialize(url:, headers: {}, options: {}, stored_cookies: {})
279
289
  @url = url
280
290
  @headers = headers
281
291
  @options = options
292
+ @stored_cookies = stored_cookies
282
293
  end
283
294
 
295
+ # @return [String] omits options and stored cookies (session credentials)
296
+ def inspect = "#<#{self.class} url=#{url}>"
297
+
284
298
  # @return [Hash] payload for POST /scrape (explicit options plus wait cap)
285
299
  def request_payload
286
300
  payload = { url: url.to_s }.merge(filtered_options)
287
301
  forwarded_headers = merged_headers
288
302
  payload[:headers] = forwarded_headers if forwarded_headers&.any?
289
303
  cap_wait_timeout!(payload)
304
+ merge_cookies!(payload)
290
305
  payload
291
306
  end
292
307
 
@@ -302,7 +317,7 @@ module Html2rss
302
317
 
303
318
  private
304
319
 
305
- attr_reader :url, :headers, :options
320
+ attr_reader :url, :headers, :options, :stored_cookies
306
321
 
307
322
  def json_object(body)
308
323
  payload = JSON.parse(body)
@@ -326,6 +341,13 @@ module Html2rss
326
341
  end
327
342
  end
328
343
 
344
+ # Stored cookies are replayed in every execution_mode; cookies from the feed config win.
345
+ def merge_cookies!(payload)
346
+ configured = (options[:cookies] || {}).transform_keys(&:to_s)
347
+ merged = stored_cookies.merge(configured)
348
+ merged.any? ? payload[:cookies] = merged : payload.delete(:cookies)
349
+ end
350
+
329
351
  def cap_wait_timeout!(payload)
330
352
  wait = payload[:wait_timeout_seconds]
331
353
  return unless wait
@@ -8,7 +8,7 @@ module Html2rss
8
8
  class RequestService
9
9
  ##
10
10
  # Strategy to delegate fetching to a Botasaurus scrape API.
11
- class BotasaurusStrategy < Strategy
11
+ class BotasaurusStrategy < Strategy # rubocop:disable Metrics/ClassLength -- transport + cookie-jar retry stay co-located
12
12
  # Content-Type negotiation for the scrape API transport hop.
13
13
  TRANSPORT_ACCEPT = 'application/json'
14
14
  # Disable compressed bodies so HTTPX returns raw JSON without implicit decoding surprises.
@@ -18,16 +18,49 @@ module Html2rss
18
18
 
19
19
  private
20
20
 
21
+ # With a stored jar, tries the cheap HTTP tier first; cookies never reach the browser tier,
22
+ # so a block falls back to a plain scrape without them.
21
23
  def fetch
22
- parsed_response = post_scrape_request
24
+ stored = jar.stored
25
+ return scrape({}) unless replay?(stored)
26
+
27
+ replay(stored)
28
+ end
29
+
30
+ def replay?(stored) = botasaurus_options[:execution_mode] != 'browser' && jar.replayable?(stored)
31
+
32
+ def replay(stored)
33
+ response = scrape(stored, execution_mode: 'request')
34
+ jar.replayed(:pass, stored)
35
+ response
36
+ rescue BlockedSurfaceDetected
37
+ jar.replayed(:block, stored)
38
+ scrape({})
39
+ end
40
+
41
+ def scrape(stored, overrides = {})
42
+ parsed_response = post_scrape_request(stored, overrides)
23
43
  raise_from_error!(parsed_response) if parsed_response.is_a?(BotasaurusContract::Error)
24
44
 
25
- build_response(parsed_response)
45
+ response = build_response(parsed_response)
46
+ ResponseGuard.new(policy: ctx.policy).inspect_body!(response.body)
47
+ jar.refresh(stored, response.cookies)
48
+ response
26
49
  end
27
50
 
28
- def post_scrape_request
51
+ def jar
52
+ @jar ||= CookieJar.new(
53
+ host: ctx.url.host, scraper_base_url: scraper_base_url.to_s, proxy: botasaurus_options[:proxy],
54
+ user_agent: botasaurus_options[:user_agent] || ctx.headers['User-Agent']
55
+ )
56
+ end
57
+
58
+ def botasaurus_options = ctx.request.fetch(:botasaurus, {})
59
+
60
+ def post_scrape_request(stored, overrides)
29
61
  request_id = SecureRandom.uuid
30
62
  Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
63
+ contract = contract(stored, overrides)
31
64
  payload = JSON.generate(contract.request_payload)
32
65
  response = client.post(scrape_endpoint, body: payload, headers: post_headers(request_id))
33
66
  raise response.error if response.is_a?(HTTPX::ErrorResponse)
@@ -44,7 +77,8 @@ module Html2rss
44
77
  url: response_url(parsed_response.final_url),
45
78
  status: parsed_response.status,
46
79
  transport_meta: parsed_response.transport_meta,
47
- captured_responses: parsed_response.xhr_responses
80
+ captured_responses: parsed_response.xhr_responses,
81
+ cookies: parsed_response.cookies
48
82
  )
49
83
  end
50
84
 
@@ -65,15 +99,13 @@ module Html2rss
65
99
  return ctx.url if final_url.nil?
66
100
 
67
101
  Html2rss::Url.from_absolute(final_url)
68
- rescue ArgumentError
102
+ rescue Html2rss::Url::InvalidUrlError
69
103
  ctx.url
70
104
  end
71
105
 
72
- def contract
73
- @contract ||= BotasaurusContract.new(
74
- url: ctx.url,
75
- headers: ctx.headers,
76
- options: ctx.request.fetch(:botasaurus, {})
106
+ def contract(stored, overrides)
107
+ BotasaurusContract.new(
108
+ url: ctx.url, headers: ctx.headers, options: botasaurus_options.merge(overrides), stored_cookies: stored
77
109
  )
78
110
  end
79
111
 
@@ -117,7 +149,7 @@ module Html2rss
117
149
  raise BotasaurusConfigurationError, 'BOTASAURUS_SCRAPER_URL is required for strategy=botasaurus.'
118
150
  end
119
151
  Html2rss::Url.for_channel(configured)
120
- rescue ArgumentError => error
152
+ rescue Html2rss::Url::InvalidUrlError => error
121
153
  raise BotasaurusConfigurationError, "BOTASAURUS_SCRAPER_URL is invalid: #{error.message}"
122
154
  end
123
155
  end
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # @param max_requests [Integer] maximum HTTP request slots
12
12
  # @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
13
13
  def initialize(max_requests:, total_timeout_seconds: nil)
14
- validate_slot_limits!(max_requests:)
14
+ Option.integer(:max_requests, max_requests, min: 1)
15
15
 
16
16
  @remaining_requests = max_requests
17
17
  @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -25,33 +25,20 @@ module Html2rss
25
25
  # @return [Integer] remaining request slots after consumption
26
26
  # @raise [RequestBudgetExceeded] if no request slots remain
27
27
  def consume!
28
- consume_request!
29
- end
30
-
31
- ##
32
- # Consumes one HTTP request slot.
33
- #
34
- # @return [Integer] remaining request slots after consumption
35
- # @raise [RequestBudgetExceeded] if no request slots remain
36
- def consume_request!
37
28
  @mutex.synchronize do
38
29
  raise RequestBudgetExceeded, 'Request budget exhausted' if @remaining_requests.zero?
39
30
 
40
31
  @remaining_requests -= 1
41
32
  end
42
33
  end
43
-
44
- ##
45
- # @return [Integer] HTTP request slots still available
46
- def remaining
47
- remaining_requests
48
- end
34
+ alias consume_request! consume!
49
35
 
50
36
  ##
51
37
  # @return [Integer] HTTP request slots still available
52
38
  def remaining_requests
53
39
  @mutex.synchronize { @remaining_requests }
54
40
  end
41
+ alias remaining remaining_requests
55
42
 
56
43
  ##
57
44
  # @return [Float, nil] the remaining timeout in seconds, or nil if not tracked
@@ -91,14 +78,6 @@ module Html2rss
91
78
  def effective_timeout_ms(fallback:)
92
79
  (effective_timeout_seconds(fallback:) * 1000).to_i
93
80
  end
94
-
95
- private
96
-
97
- def validate_slot_limits!(max_requests:)
98
- return if max_requests.is_a?(Integer) && max_requests.positive?
99
-
100
- raise ArgumentError, 'max_requests must be positive'
101
- end
102
81
  end
103
82
  end
104
83
  end