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
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Html2rss
6
+ class RequestService
7
+ ##
8
+ # In-process cookie jar for anti-bot clearance cookies returned by botasaurus-scrape-api.
9
+ #
10
+ # The scrape API is stateless, so html2rss owns reuse. Any object responding to
11
+ # +fetch(key)+, +write(key, cookies, ttl:)+ and +evict(key)+ can replace this store via
12
+ # {RequestService.cookie_store=} (e.g. Redis or a database in html2rss-web).
13
+ #
14
+ # Values are session credentials: never log, dump or persist them in feed config.
15
+ class CookieStore
16
+ # Seconds stored cookies are replayed (the API returns no expiry; 0 disables reuse).
17
+ DEFAULT_TTL_SECONDS = Integer(ENV.fetch('BOTASAURUS_COOKIE_TTL_SECONDS', 1800))
18
+
19
+ ##
20
+ # Builds a jar key from everything a clearance cookie is bound to.
21
+ # The digest keeps proxy credentials out of keys, logs and external stores.
22
+ #
23
+ # @param host [String] target host
24
+ # @param scraper_base_url [String] scrape-api base URL
25
+ # @param proxy [String, nil] egress proxy
26
+ # @param user_agent [String, nil] browser user agent
27
+ # @return [String] opaque jar key
28
+ def self.key(host:, scraper_base_url:, proxy:, user_agent:)
29
+ Digest::SHA256.hexdigest([host, scraper_base_url, proxy, user_agent].join("\0"))
30
+ end
31
+
32
+ def initialize
33
+ @entries = {}
34
+ @mutex = Mutex.new
35
+ end
36
+
37
+ ##
38
+ # @param key [String] jar key
39
+ # @return [Hash{String => String}] live cookies, {} when absent or expired
40
+ def fetch(key)
41
+ @mutex.synchronize do
42
+ cookies, expires_at = @entries[key]
43
+ next {} if cookies.nil?
44
+ next cookies.dup if now < expires_at
45
+
46
+ @entries.delete(key)
47
+ {}
48
+ end
49
+ end
50
+
51
+ ##
52
+ # @param key [String] jar key
53
+ # @param cookies [Hash{String => String}] cookies to keep
54
+ # @param ttl [Numeric] seconds until expiry
55
+ # @return [void]
56
+ def write(key, cookies, ttl: DEFAULT_TTL_SECONDS)
57
+ @mutex.synchronize do
58
+ purge_expired
59
+ @entries[key] = [cookies.dup.freeze, now + ttl]
60
+ end
61
+ end
62
+
63
+ ##
64
+ # @param key [String] jar key
65
+ # @return [void]
66
+ def evict(key)
67
+ @mutex.synchronize { @entries.delete(key) }
68
+ end
69
+
70
+ private
71
+
72
+ def purge_expired
73
+ @entries.delete_if { |_key, (_cookies, expires_at)| expires_at <= now }
74
+ end
75
+
76
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ ##
6
+ # Proof that a page advertises feed URLs in its head (+link[rel=alternate]+ with an
7
+ # RSS/Atom/RDF type and an +http(s)+ href, see {Html::FeedLink}).
8
+ #
9
+ # Built only from a parsed document ({.from_document}), so a caller cannot mint permission
10
+ # for an arbitrary URL: +new+ and +allocate+ are private, an instance and its +feeds+ are
11
+ # frozen, and +dup+/+clone+ return the instance itself (there is no +with+ copy).
12
+ # Reflection (+send(:new)+, +instance_variable_set+ on an allocated object, +Marshal.load+)
13
+ # still can, as nothing in-process can stop it; {Policy} also rejects an unfrozen proof.
14
+ #
15
+ # {Policy} lets an +:advertised_feed+ follow-up leave the page origin only for a URL listed
16
+ # here. A feed off the page origin is recorded as +https+ (an advertised +http:+ feed is
17
+ # fetched as +https:+ or not at all).
18
+ #
19
+ # +feeds+ maps each advertised feed URL to its link title, in document order.
20
+ class FeedAdvertisement
21
+ private_class_method :new, :allocate
22
+
23
+ ##
24
+ # @param doc [Nokogiri::HTML::Document] parsed page
25
+ # @param page_url [String, Html2rss::Url] URL the page was fetched from
26
+ # @return [FeedAdvertisement] the page's advertised feeds (possibly none)
27
+ def self.from_document(doc, page_url:)
28
+ page = Url.from_absolute(page_url)
29
+ feeds = Html::FeedLink.from_document(doc).each_with_object({}) do |link, acc|
30
+ url = feed_url(link.href, page)
31
+ acc[url] ||= link.title if url
32
+ end
33
+ new(page_url: page, feeds: feeds.freeze)
34
+ end
35
+
36
+ def self.feed_url(href, page)
37
+ return unless (url = Url.resolve_web(href, page))
38
+
39
+ Policy.same_origin?(url, page) ? url : url.with_scheme('https')
40
+ end
41
+ private_class_method :feed_url
42
+
43
+ ##
44
+ # @return [Html2rss::Url] the page the feeds were read from
45
+ attr_reader :page_url
46
+
47
+ # @return [Hash{Html2rss::Url => String, nil}] advertised feed URL => link title, in document order
48
+ attr_reader :feeds
49
+
50
+ # @param page_url [Html2rss::Url]
51
+ # @param feeds [Hash{Html2rss::Url => String, nil}] frozen
52
+ def initialize(page_url:, feeds:)
53
+ @page_url = page_url
54
+ @feeds = feeds
55
+ freeze
56
+ end
57
+
58
+ ##
59
+ # @param url [Html2rss::Url]
60
+ # @return [Boolean] whether the page advertises +url+
61
+ def advertises?(url) = feeds.key?(url)
62
+
63
+ # @return [FeedAdvertisement] itself: a proof is immutable, so no copy is ever minted
64
+ def dup = self
65
+
66
+ # The proof stays frozen whatever +freeze:+ asks.
67
+ #
68
+ # @return [FeedAdvertisement] itself
69
+ def clone(**) = self
70
+ end
71
+ end
72
+ end
@@ -154,7 +154,7 @@ module Html2rss
154
154
  end
155
155
 
156
156
  def begin_terminal_url_request!(terminal_url)
157
- ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
157
+ validate_redirect!(terminal_url)
158
158
  @request_url_override = terminal_url
159
159
  @last_redirect_to = nil
160
160
  @current_url = terminal_url
@@ -213,14 +213,18 @@ module Html2rss
213
213
  def redirect_callback
214
214
  lambda do |redirect_uri|
215
215
  to_url = normalize_url(redirect_uri)
216
- from_url = @current_url
217
216
  @last_redirect_to = to_url
218
- ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
217
+ validate_redirect!(to_url)
219
218
  @current_url = to_url
220
219
  true
221
220
  end
222
221
  end
223
222
 
223
+ def validate_redirect!(to_url)
224
+ ctx.policy.validate_redirect!(from_url: @current_url, to_url:, origin_url: ctx.origin_url,
225
+ relation: ctx.relation, advertisement: ctx.advertisement)
226
+ end
227
+
224
228
  def build_response(response)
225
229
  headers = response.headers.to_h
226
230
  Response.new(
@@ -20,11 +20,11 @@ module Html2rss
20
20
 
21
21
  ##
22
22
  # @return [Response] the mock response wrapped around the file contents
23
- # @raise [ArgumentError] if the local file path is missing
23
+ # @raise [InvalidOption] if the local file path is missing
24
24
  # @raise [Errno::ENOENT] if the file does not exist
25
25
  def fetch
26
26
  file_path = ctx.request[:local_file_path]
27
- raise ArgumentError, 'Local file path is required for local_file strategy' unless file_path
27
+ raise InvalidOption, 'Local file path is required for local_file strategy' unless file_path
28
28
 
29
29
  body = File.read(file_path)
30
30
  Response.new(
@@ -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
@@ -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,24 +168,28 @@ module Html2rss
135
168
 
136
169
  attr_reader :network_guard
137
170
 
171
+ # Yields only for a cross-origin follow-up: a truthy block admits it.
138
172
  def enforce_same_origin!(url, origin_url, relation)
139
173
  return if relation == :initial || allow_cross_origin_followups?
140
174
 
141
175
  enforce_follow_up_scheme!(url, origin_url)
142
- return if comparable_origin(url) == comparable_origin(origin_url)
176
+ return if self.class.same_origin?(url, origin_url) || yield
143
177
 
144
178
  raise CrossOriginFollowUpDenied, "Cross-origin follow-up denied for #{url}"
145
179
  end
146
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
+
147
188
  def enforce_follow_up_scheme!(url, origin_url)
148
189
  return unless origin_url.scheme == 'https' && url.scheme == 'http'
149
190
 
150
191
  raise UnsupportedUrlScheme, "Follow-up downgraded from https to http for #{url}"
151
192
  end
152
-
153
- def comparable_origin(url)
154
- [url.scheme, url.host, url.port]
155
- end
156
193
  end
157
194
 
158
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,20 +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
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
34
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
44
  @headers = (headers || {}).transform_keys(&:to_s)
40
45
  @status = status
41
46
  @url = url
42
- @transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
43
- @captured_responses = if captured_responses.nil? || captured_responses.empty?
44
- EMPTY_CAPTURED_RESPONSES
45
- else
46
- captured_responses.freeze
47
- 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)
48
50
  end
49
51
 
50
52
  # @return [String] the raw body of the response
@@ -65,6 +67,19 @@ module Html2rss
65
67
  # @return [Array<Hash>] captured JSON XHR/fetch responses (empty when unsupported)
66
68
  attr_reader :captured_responses
67
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
+
68
83
  # @return [String] normalized content type header value
69
84
  def content_type = header('content-type').to_s
70
85
 
@@ -79,7 +94,7 @@ module Html2rss
79
94
  content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
80
95
  end
81
96
 
82
- # @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)
83
98
  def feed_response?
84
99
  return @feed_response if defined?(@feed_response)
85
100
 
@@ -101,6 +116,8 @@ module Html2rss
101
116
 
102
117
  private
103
118
 
119
+ def freeze_or(value, default) = value.nil? || value.empty? ? default : value.freeze
120
+
104
121
  # @param name [String] canonical header name
105
122
  # @return [Object, nil] header value when present
106
123
  def header(name)
@@ -155,7 +172,7 @@ module Html2rss
155
172
 
156
173
  def feed_content_type?
157
174
  ct = content_type.downcase
158
- return false if ct.empty? || ct.include?('html') || ct.include?('json')
175
+ return false if ct.include?('html') || ct.include?('json')
159
176
 
160
177
  # Substring match against Content-Type (not Array#intersect? on a String).
161
178
  FEED_CT_MARKERS.any? { |marker| ct.include?(marker) }
@@ -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