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
@@ -9,18 +9,26 @@ module Html2rss
9
9
  #
10
10
  # Public façade for request limits, same-origin/follow-up rules, and
11
11
  # network reachability. DNS/host/IP enforcement lives on {NetworkGuard}.
12
+ #
13
+ # Follow-ups stay on the origin (scheme, host, port) unless the relation is
14
+ # +:advertised_feed+ and the URL is listed in the page's {FeedAdvertisement}; redirects
15
+ # of such a request may change host over +https+. Budget, scheme, public-network
16
+ # and response-size guards apply to every request and hop either way.
12
17
  class Policy
13
18
  # Hard ceiling for configured max_requests during a feed build.
14
19
  MAX_REQUESTS_CEILING = 10
15
20
 
21
+ # Default response-size ceiling (10 MiB); owns both streamed and decompressed defaults.
22
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024
23
+
16
24
  # Default policy values used when request controls are not explicitly set.
17
25
  DEFAULTS = {
18
26
  connect_timeout_seconds: Integer(ENV.fetch('HTML2RSS_CONNECT_TIMEOUT_SECONDS', 5)),
19
27
  read_timeout_seconds: Integer(ENV.fetch('HTML2RSS_READ_TIMEOUT_SECONDS', 10)),
20
28
  total_timeout_seconds: Integer(ENV.fetch('HTML2RSS_TOTAL_TIMEOUT_SECONDS', 30)),
21
29
  max_redirects: 5,
22
- max_response_bytes: 5_242_880,
23
- max_decompressed_bytes: 10_485_760,
30
+ max_response_bytes: MAX_RESPONSE_BYTES,
31
+ max_decompressed_bytes: MAX_RESPONSE_BYTES,
24
32
  max_requests: 1,
25
33
  allow_private_networks: false,
26
34
  allow_cross_origin_followups: false
@@ -47,13 +55,13 @@ module Html2rss
47
55
  allow_private_networks: DEFAULTS[:allow_private_networks],
48
56
  allow_cross_origin_followups: DEFAULTS[:allow_cross_origin_followups],
49
57
  resolver: Socket)
50
- @connect_timeout_seconds = validate_positive_integer!(:connect_timeout_seconds, connect_timeout_seconds)
51
- @read_timeout_seconds = validate_positive_integer!(:read_timeout_seconds, read_timeout_seconds)
52
- @total_timeout_seconds = validate_positive_integer!(:total_timeout_seconds, total_timeout_seconds)
53
- @max_redirects = validate_non_negative_integer!(:max_redirects, max_redirects)
54
- @max_response_bytes = validate_positive_integer!(:max_response_bytes, max_response_bytes)
55
- @max_decompressed_bytes = validate_positive_integer!(:max_decompressed_bytes, max_decompressed_bytes)
56
- @max_requests = [validate_positive_integer!(:max_requests, max_requests), MAX_REQUESTS_CEILING].min
58
+ @connect_timeout_seconds = Option.integer(:connect_timeout_seconds, connect_timeout_seconds, min: 1)
59
+ @read_timeout_seconds = Option.integer(:read_timeout_seconds, read_timeout_seconds, min: 1)
60
+ @total_timeout_seconds = Option.integer(:total_timeout_seconds, total_timeout_seconds, min: 1)
61
+ @max_redirects = Option.integer(:max_redirects, max_redirects, min: 0)
62
+ @max_response_bytes = Option.integer(:max_response_bytes, max_response_bytes, min: 1)
63
+ @max_decompressed_bytes = Option.integer(:max_decompressed_bytes, max_decompressed_bytes, min: 1)
64
+ @max_requests = [Option.integer(:max_requests, max_requests, min: 1), MAX_REQUESTS_CEILING].min
57
65
  @allow_private_networks = allow_private_networks ? true : false
58
66
  @allow_cross_origin_followups = allow_cross_origin_followups ? true : false
59
67
  @network_guard = NetworkGuard.new(allow_private_networks: @allow_private_networks, resolver:)
@@ -80,6 +88,12 @@ module Html2rss
80
88
  @allow_cross_origin_followups
81
89
  end
82
90
 
91
+ ##
92
+ # @return [Array] the policy fields that shape a fetched response (for response caching)
93
+ def cache_key
94
+ [allow_private_networks?, max_redirects, max_response_bytes, max_decompressed_bytes]
95
+ end
96
+
83
97
  ##
84
98
  # Returns the default request policy.
85
99
  #
@@ -89,17 +103,30 @@ module Html2rss
89
103
  new
90
104
  end
91
105
 
106
+ ##
107
+ # Whether two URLs share an origin (scheme, host, and port).
108
+ #
109
+ # @param url [Html2rss::Url]
110
+ # @param other [Html2rss::Url]
111
+ # @return [Boolean]
112
+ def self.same_origin?(url, other)
113
+ [url.scheme, url.host, url.port] == [other.scheme, other.host, other.port]
114
+ end
115
+
92
116
  ##
93
117
  # Validates whether a request target is permitted for the given context.
94
118
  #
95
119
  # @param url [Html2rss::Url] destination URL
96
120
  # @param origin_url [Html2rss::Url] initial URL of the feed build
97
121
  # @param relation [Symbol] logical reason for the request
122
+ # @param advertisement [FeedAdvertisement, nil] the page's advertised feeds (+:advertised_feed+ only)
98
123
  # @return [void]
99
124
  # @raise [CrossOriginFollowUpDenied] if a follow-up leaves the origin host
100
125
  # @raise [PrivateNetworkDenied] if the target resolves to a private address
101
- def validate_request!(url:, origin_url:, relation:)
102
- enforce_same_origin!(url, origin_url, relation)
126
+ def validate_request!(url:, origin_url:, relation:, advertisement: nil)
127
+ enforce_same_origin!(url, origin_url, relation) do
128
+ advertised_feed?(origin_url, relation, advertisement) && advertisement.advertises?(url)
129
+ end
103
130
  network_guard.enforce_public_network!(url)
104
131
  end
105
132
 
@@ -110,14 +137,20 @@ module Html2rss
110
137
  # @param to_url [Html2rss::Url] redirect destination
111
138
  # @param origin_url [Html2rss::Url] initial URL of the feed build
112
139
  # @param relation [Symbol] logical reason for the request
140
+ # @param advertisement [FeedAdvertisement, nil] the page's advertised feeds (+:advertised_feed+ only)
113
141
  # @return [void]
114
142
  # @raise [UnsupportedUrlScheme] if the redirect downgrades from HTTPS to HTTP
115
- def validate_redirect!(from_url:, to_url:, origin_url:, relation:)
143
+ # @raise [CrossOriginFollowUpDenied] if a follow-up hop leaves the origin host
144
+ # @raise [PrivateNetworkDenied] if the hop resolves to a private address
145
+ def validate_redirect!(from_url:, to_url:, origin_url:, relation:, advertisement: nil)
116
146
  if from_url.scheme == 'https' && to_url.scheme == 'http'
117
147
  raise UnsupportedUrlScheme, "Redirect downgraded from #{from_url} to #{to_url}"
118
148
  end
119
149
 
120
- validate_request!(url: to_url, origin_url:, relation:)
150
+ enforce_same_origin!(to_url, origin_url, relation) do
151
+ advertised_feed?(origin_url, relation, advertisement) && to_url.scheme == 'https'
152
+ end
153
+ network_guard.enforce_public_network!(to_url)
121
154
  end
122
155
 
123
156
  ##
@@ -135,42 +168,28 @@ module Html2rss
135
168
 
136
169
  attr_reader :network_guard
137
170
 
138
- def validate_positive_integer!(name, value)
139
- raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
140
-
141
- value
142
- end
143
-
144
- def validate_non_negative_integer!(name, value)
145
- raise ArgumentError, "#{name} must be non-negative" unless value.is_a?(Integer) && !value.negative?
146
-
147
- value
148
- end
149
-
171
+ # Yields only for a cross-origin follow-up: a truthy block admits it.
150
172
  def enforce_same_origin!(url, origin_url, relation)
151
173
  return if relation == :initial || allow_cross_origin_followups?
152
174
 
153
175
  enforce_follow_up_scheme!(url, origin_url)
154
- return if comparable_origin(url) == comparable_origin(origin_url)
176
+ return if self.class.same_origin?(url, origin_url) || yield
155
177
 
156
178
  raise CrossOriginFollowUpDenied, "Cross-origin follow-up denied for #{url}"
157
179
  end
158
180
 
181
+ # The proof must come from the page this follow-up originates from; an unfrozen one was not built by
182
+ # {FeedAdvertisement.from_document}.
183
+ def advertised_feed?(origin_url, relation, advertisement)
184
+ relation == :advertised_feed && advertisement.instance_of?(FeedAdvertisement) && advertisement.frozen? &&
185
+ self.class.same_origin?(advertisement.page_url, origin_url)
186
+ end
187
+
159
188
  def enforce_follow_up_scheme!(url, origin_url)
160
189
  return unless origin_url.scheme == 'https' && url.scheme == 'http'
161
190
 
162
191
  raise UnsupportedUrlScheme, "Follow-up downgraded from https to http for #{url}"
163
192
  end
164
-
165
- def comparable_origin(url)
166
- [url.host, normalized_port(url)]
167
- end
168
-
169
- def normalized_port(url)
170
- return url.port if url.port
171
-
172
- url.scheme == 'https' ? 443 : 80
173
- end
174
193
  end
175
194
 
176
195
  # Shared immutable policy instance used for default request execution.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ ##
6
+ # Process-local circuit breaker for cookie replay: after {MAX_BLOCKS} consecutive replay-then-block
7
+ # outcomes for a jar key, replay stays off until the cookie TTL elapses.
8
+ class ReplayBreaker
9
+ # Consecutive blocked replays before the breaker opens.
10
+ MAX_BLOCKS = Integer(ENV.fetch('BOTASAURUS_COOKIE_REPLAY_MAX_BLOCKS', 3))
11
+
12
+ def initialize
13
+ @state = {}
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ ##
18
+ # @param key [String] jar key
19
+ # @return [Boolean] true while replay is suspended for the key
20
+ def open?(key)
21
+ @mutex.synchronize do
22
+ open_until = @state[key]&.last
23
+ next false unless open_until
24
+ next true if now < open_until
25
+
26
+ @state.delete(key)
27
+ false
28
+ end
29
+ end
30
+
31
+ ##
32
+ # @param key [String] jar key
33
+ # @param ttl [Numeric] seconds replay stays off once the breaker trips
34
+ # @return [Boolean] true when this block tripped the breaker
35
+ def record_block(key, ttl:)
36
+ @mutex.synchronize do
37
+ blocks = (@state[key]&.first || 0) + 1
38
+ tripped = blocks >= MAX_BLOCKS
39
+ @state[key] = [blocks, (now + ttl if tripped)]
40
+ tripped
41
+ end
42
+ end
43
+
44
+ ##
45
+ # @param key [String] jar key
46
+ # @return [void]
47
+ def record_pass(key)
48
+ @mutex.synchronize { @state.delete(key) }
49
+ end
50
+
51
+ private
52
+
53
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ end
55
+ end
56
+ end
@@ -6,18 +6,20 @@ module Html2rss
6
6
  class RequestService
7
7
  ##
8
8
  # To be used by strategies to provide their response.
9
- class Response
9
+ class Response # rubocop:disable Metrics/ClassLength -- transport + capture + cache fields stay co-located
10
10
  # Default when a strategy does not attach transport telemetry.
11
11
  EMPTY_TRANSPORT_META = {}.freeze
12
12
  # Default when a strategy does not capture sub-resource responses.
13
13
  EMPTY_CAPTURED_RESPONSES = [].freeze
14
+ # Default when a strategy returns no cookies.
15
+ EMPTY_COOKIES = {}.freeze
14
16
 
15
17
  # Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
16
18
  HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
17
- # Content-Type markers for RSS/Atom syndication responses.
18
- FEED_CT_MARKERS = %w[rss+xml atom+xml rss atom].freeze
19
+ # Content-Type markers for RSS/Atom/RDF syndication responses.
20
+ FEED_CT_MARKERS = %w[rss+xml atom+xml rdf+xml rss atom].freeze
19
21
  # Body sniff for unlabeled syndication (first 800 bytes, downcased).
20
- FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed '].freeze
22
+ FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed ', '<rdf:rdf'].freeze
21
23
  # Charset from Content-Type or a leading <meta charset>.
22
24
  CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
23
25
  # Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
@@ -31,23 +33,20 @@ module Html2rss
31
33
  # @param status [Integer, nil] the HTTP status code when available
32
34
  # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
33
35
  # @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
34
- # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
36
+ # @param cookies [Hash{String => String}] cookies earned during the scrape (session credentials; never log)
37
+ # @param cache_age [Integer, nil] seconds since fetch when served from {ResponseCache}; nil when fetched live
38
+ # rubocop:disable-next Metrics/ParameterLists -- transport + capture fields stay co-located
35
39
  def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
36
- captured_responses: EMPTY_CAPTURED_RESPONSES)
40
+ captured_responses: EMPTY_CAPTURED_RESPONSES, cookies: EMPTY_COOKIES, cache_age: nil)
37
41
  @body = body
42
+ @cache_age = cache_age
38
43
 
39
- headers = (headers || {}).transform_keys(&:to_s)
40
- HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
41
-
42
- @headers = headers
44
+ @headers = (headers || {}).transform_keys(&:to_s)
43
45
  @status = status
44
46
  @url = url
45
- @transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
46
- @captured_responses = if captured_responses.nil? || captured_responses.empty?
47
- EMPTY_CAPTURED_RESPONSES
48
- else
49
- captured_responses.freeze
50
- end
47
+ @transport_meta = freeze_or(transport_meta, EMPTY_TRANSPORT_META)
48
+ @cookies = freeze_or(cookies, EMPTY_COOKIES)
49
+ @captured_responses = freeze_or(captured_responses, EMPTY_CAPTURED_RESPONSES)
51
50
  end
52
51
 
53
52
  # @return [String] the raw body of the response
@@ -68,6 +67,19 @@ module Html2rss
68
67
  # @return [Array<Hash>] captured JSON XHR/fetch responses (empty when unsupported)
69
68
  attr_reader :captured_responses
70
69
 
70
+ # @return [Hash{String => String}] cookies earned during the scrape (empty when unsupported)
71
+ attr_reader :cookies
72
+
73
+ # @return [Integer, nil] seconds since fetch when served from {ResponseCache}; nil when fetched live
74
+ attr_reader :cache_age
75
+
76
+ # @param cache_age [Integer, nil] cache age of the copy (defaults to this response's)
77
+ # @return [Response] an independent copy that shares no parsed document with this one
78
+ def replica(cache_age: @cache_age)
79
+ self.class.new(body: body.dup, url:, headers:, status:, transport_meta:, captured_responses:, cookies:,
80
+ cache_age:)
81
+ end
82
+
71
83
  # @return [String] normalized content type header value
72
84
  def content_type = header('content-type').to_s
73
85
 
@@ -82,7 +94,7 @@ module Html2rss
82
94
  content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
83
95
  end
84
96
 
85
- # @return [Boolean] whether response content is RSS/Atom (header or sniffed body)
97
+ # @return [Boolean] whether response content is RSS/Atom/RDF (header or sniffed body)
86
98
  def feed_response?
87
99
  return @feed_response if defined?(@feed_response)
88
100
 
@@ -104,6 +116,8 @@ module Html2rss
104
116
 
105
117
  private
106
118
 
119
+ def freeze_or(value, default) = value.nil? || value.empty? ? default : value.freeze
120
+
107
121
  # @param name [String] canonical header name
108
122
  # @return [Object, nil] header value when present
109
123
  def header(name)
@@ -158,14 +172,14 @@ module Html2rss
158
172
 
159
173
  def feed_content_type?
160
174
  ct = content_type.downcase
161
- return false if ct.empty? || ct.include?('html') || ct.include?('json')
175
+ return false if ct.include?('html') || ct.include?('json')
162
176
 
163
177
  # Substring match against Content-Type (not Array#intersect? on a String).
164
178
  FEED_CT_MARKERS.any? { |marker| ct.include?(marker) }
165
179
  end
166
180
 
167
181
  def feed_looking_body?
168
- snippet = body.to_s[0, 800].downcase
182
+ snippet = body.to_s.b[0, 800].downcase
169
183
  FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) }
170
184
  end
171
185
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ ##
6
+ # Bounded, thread-safe TTL cache for initial page fetches; install with
7
+ # {RequestService.response_cache=}. Only successful (2xx) responses are stored, so a
8
+ # block page or rate limit is never replayed. Hits are returned tagged with their
9
+ # {Response#cache_age}; memory is capped in body bytes as well as entries.
10
+ class ResponseCache
11
+ # Default entry ceiling; oldest entries are evicted first.
12
+ DEFAULT_MAX_ENTRIES = 32
13
+ # Default body-byte budget across all entries.
14
+ DEFAULT_MAX_BYTES = 32 * 1024 * 1024
15
+ # Monotonic seconds source, immune to wall-clock jumps.
16
+ MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
17
+ Entry = Data.define(:stored_at, :response, :bytes)
18
+ private_constant :Entry
19
+
20
+ # @return [Integer] seconds an entry stays fresh
21
+ attr_reader :ttl
22
+
23
+ ##
24
+ # @param ttl [Integer] seconds an entry stays fresh
25
+ # @param max_entries [Integer] maximum stored entries
26
+ # @param max_bytes [Integer] maximum summed body bytes
27
+ # @param clock [#call] monotonic seconds source (injectable for specs)
28
+ def initialize(ttl:, max_entries: DEFAULT_MAX_ENTRIES, max_bytes: DEFAULT_MAX_BYTES, clock: MONOTONIC_CLOCK)
29
+ @ttl = Option.integer(:ttl, ttl, min: 1)
30
+ @max_entries = Option.integer(:max_entries, max_entries, min: 1)
31
+ @max_bytes = Option.integer(:max_bytes, max_bytes, min: 1)
32
+ @clock = clock
33
+ @entries = {}
34
+ @bytes = 0
35
+ @mutex = Mutex.new
36
+ @flight_done = ConditionVariable.new
37
+ @inflight = Set.new
38
+ end
39
+
40
+ ##
41
+ # Concurrent callers missing the same key share one fetch: the first builds the response while
42
+ # the others wait, then each is served its own replica. A raising or uncacheable fetch is not
43
+ # stored; waiters then retry in turn so none is left blocked.
44
+ # @param key [Object] hashable cache key
45
+ # @yieldreturn [Response] the freshly fetched response, built only on a miss
46
+ # @return [Response] a cache-aged replica on a hit, otherwise the freshly built response
47
+ def fetch(key, &)
48
+ cached = claim(key)
49
+ return cached if cached
50
+
51
+ lead(key, &)
52
+ end
53
+
54
+ private
55
+
56
+ # Returns a fresh replica, or nil after registering the caller as the key's fetcher.
57
+ def claim(key)
58
+ @mutex.synchronize do
59
+ loop do
60
+ hit = lookup(key)
61
+ break hit if hit
62
+ break @inflight.add(key) && nil unless @inflight.include?(key)
63
+
64
+ @flight_done.wait(@mutex)
65
+ end
66
+ end
67
+ end
68
+
69
+ def lead(key)
70
+ yield.tap { |response| store(key, response) if cacheable?(response) }
71
+ ensure
72
+ @mutex.synchronize do
73
+ @inflight.delete(key)
74
+ @flight_done.broadcast
75
+ end
76
+ end
77
+
78
+ # Callers must hold @mutex.
79
+ def lookup(key)
80
+ entry = @entries[key]
81
+ age = entry && (@clock.call - entry.stored_at)
82
+ entry.response.replica(cache_age: age.floor) if age && age < @ttl
83
+ end
84
+
85
+ def store(key, response)
86
+ @mutex.synchronize do
87
+ evict(key)
88
+ bytes = response.body.bytesize
89
+ @entries[key] = Entry.new(stored_at: @clock.call, response:, bytes:)
90
+ @bytes += bytes
91
+ evict_oldest while @entries.size > @max_entries || @bytes > @max_bytes
92
+ end
93
+ end
94
+
95
+ def evict(key)
96
+ @bytes -= @entries.delete(key)&.bytes.to_i
97
+ end
98
+
99
+ def evict_oldest
100
+ _key, entry = @entries.shift
101
+ @bytes -= entry.bytes
102
+ end
103
+
104
+ def cacheable?(response)
105
+ (200..299).cover?(response.status) && response.body.bytesize <= @max_bytes
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Html2rss
6
+ class RequestService
7
+ ##
8
+ # Opt-in reuse of initial page fetches, e.g. while a person iterates on selectors for one page.
9
+ # Extended onto {RequestService}; the cache is consulted only for +relation == :initial+ requests,
10
+ # follow-ups always hit the network.
11
+ #
12
+ # The key is a SHA256 digest of the URL, strategy, headers, request options and the policy fields
13
+ # that shape a response, so credentials never reach the cache and a response fetched under a
14
+ # permissive {Policy} is never served to a stricter one. Cookie-store state is not part of the key.
15
+ module ResponseCaching
16
+ # Mutex guarding the process-wide response cache reference.
17
+ RESPONSE_CACHE_MUTEX = Mutex.new
18
+ # Fiber-storage key for the active {#observe_cache_hits} collector.
19
+ CACHE_HITS_KEY = :html2rss_cache_hits
20
+ # Fiber-storage key for the cache installed by {#with_response_cache}.
21
+ SCOPED_CACHE_KEY = :html2rss_scoped_response_cache
22
+ private_constant :RESPONSE_CACHE_MUTEX, :CACHE_HITS_KEY, :SCOPED_CACHE_KEY
23
+
24
+ # @param cache [#fetch, nil] receives +fetch(key) { response }+ and returns the cached or freshly
25
+ # built {Response} (tagged with {Response#cache_age} on a hit), e.g. {ResponseCache};
26
+ # +nil+ (the default) disables caching. A raising block must not be cached.
27
+ # @return [void]
28
+ def response_cache=(cache)
29
+ RESPONSE_CACHE_MUTEX.synchronize { @response_cache = cache }
30
+ end
31
+
32
+ # @return [#fetch, nil] the scoped cache from {#with_response_cache} if one is active, otherwise
33
+ # the globally configured initial-response cache, if any
34
+ def response_cache
35
+ scoped = Fiber[SCOPED_CACHE_KEY]
36
+ return scoped.first if scoped
37
+
38
+ RESPONSE_CACHE_MUTEX.synchronize { @response_cache }
39
+ end
40
+
41
+ # Runs the block with +cache+ as the initial-response cache for the current fiber (and fibers or
42
+ # threads it spawns), taking precedence over {#response_cache=}. The previous scope is restored
43
+ # when the block exits, even by raising.
44
+ #
45
+ # @param cache [#fetch, nil] cache for the block; +nil+ disables caching inside it
46
+ # @yieldreturn [Object] the block's result
47
+ # @return [Object] the block's result
48
+ def with_response_cache(cache)
49
+ previous = Fiber[SCOPED_CACHE_KEY]
50
+ Fiber[SCOPED_CACHE_KEY] = [cache]
51
+ yield
52
+ ensure
53
+ Fiber[SCOPED_CACHE_KEY] = previous
54
+ end
55
+
56
+ # Runs the block and reports the cache ages of every initial fetch it served from {#response_cache},
57
+ # including fetches on threads the block spawns (fiber storage is inherited by reference).
58
+ #
59
+ # @yieldreturn [Object] the block's result
60
+ # @return [Array(Object, Array<Integer>)] the block's result and the served cache ages in seconds
61
+ def observe_cache_hits
62
+ hits = Fiber[CACHE_HITS_KEY] = Queue.new
63
+ result = yield
64
+ [result, Array.new(hits.size) { hits.pop }]
65
+ ensure
66
+ Fiber[CACHE_HITS_KEY] = nil
67
+ end
68
+
69
+ private
70
+
71
+ ##
72
+ # Runs +fetch+ directly, or through {#response_cache} for initial requests.
73
+ # Callers always receive their own {Response} so parsed documents are never shared.
74
+ # @param ctx [Context] the request context
75
+ # @param strategy [Symbol] the resolved strategy name
76
+ # @yieldreturn [Response] the freshly fetched response
77
+ # @return [Response]
78
+ def fetch_initial_through_cache(ctx, strategy, &)
79
+ cache = response_cache
80
+ return yield unless cache && ctx.relation == :initial
81
+
82
+ cache.fetch(response_cache_key(ctx, strategy), &).replica.tap { |response| record_cache_hit(response) }
83
+ end
84
+
85
+ def record_cache_hit(response)
86
+ Fiber[CACHE_HITS_KEY]&.push(response.cache_age) if response.cache_age
87
+ end
88
+
89
+ def response_cache_key(ctx, strategy)
90
+ parts = [ctx.url.to_s, strategy, ctx.headers.sort, ctx.request, ctx.policy.cache_key]
91
+ Digest::SHA256.hexdigest(parts.inspect)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -18,12 +18,13 @@ module Html2rss
18
18
  # Executes the request through the shared preflight/postflight path.
19
19
  #
20
20
  # @return [Response] the response from the strategy
21
- # @raise [NotImplementedError] if the subclass does not implement `#fetch`
22
21
  def execute
23
22
  check_timeout!
24
23
  response = perform_execute
25
24
  postflight!(response)
26
25
  response
26
+ rescue Html2rss::ContractError
27
+ raise
27
28
  rescue StandardError => error
28
29
  handle_error(error)
29
30
  end
@@ -42,15 +43,6 @@ module Html2rss
42
43
  fetch
43
44
  end
44
45
 
45
- ##
46
- # Adapter hook: perform the transport-specific fetch after preflight.
47
- #
48
- # @return [Response] normalized response
49
- # @raise [NotImplementedError] when a subclass omits the hook
50
- def fetch
51
- raise NotImplementedError, 'Subclass must implement #fetch'
52
- end
53
-
54
46
  ##
55
47
  # Consumes a request slot and validates the destination before transport work.
56
48
  #
@@ -60,7 +52,9 @@ module Html2rss
60
52
  return if skip_preflight?
61
53
 
62
54
  ctx.budget.consume! if consume_budget
63
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
55
+ ctx.policy.validate_request!(
56
+ url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation, advertisement: ctx.advertisement
57
+ )
64
58
  end
65
59
 
66
60
  ##