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
@@ -7,56 +7,58 @@ require 'zlib'
7
7
  module Html2rss
8
8
  class RequestService
9
9
  # Decodes gzip/brotli bodies when Content-Encoding is missing or the type is octet-stream.
10
- module CompressedBody
10
+ class CompressedBody
11
11
  # Gzip stream magic (RFC 1952).
12
12
  GZIP_MAGIC = "\x1F\x8B".b.freeze
13
13
  # HTML prefix for unlabeled brotli trial decode. Same object as {Response::HTML_BODY_SNIFF}.
14
14
  HTML_BODY_SNIFF = Response::HTML_BODY_SNIFF
15
15
 
16
- module_function
17
-
18
16
  # @param body [String, nil] raw response body
19
17
  # @param headers [Hash] response headers (Content-Encoding / Content-Type)
20
18
  # @return [String, nil] decoded body when compressed; otherwise the original body
21
- def decode(body, headers: {})
22
- return body if body.nil? || body.empty?
23
-
19
+ def self.decode(body, headers: {})
24
20
  raw = body.to_s.b
25
- decode_labeled(raw, headers) || decode_unlabeled(raw, headers) || body
21
+ new(raw, headers).decoded_body || body
22
+ end
23
+
24
+ # @param raw [String] binary response body
25
+ # @param headers [Hash] response headers (Content-Encoding / Content-Type)
26
+ def initialize(raw, headers)
27
+ @raw = raw
28
+ @headers = headers
26
29
  end
30
+ private_class_method :new
27
31
 
28
- def decode_labeled(raw, headers)
29
- encodings = content_encodings(headers)
32
+ # @return [String, nil] inflated body, or nil when the bytes are not a recognized stream
33
+ def decoded_body
34
+ decode_labeled || decode_unlabeled
35
+ end
36
+
37
+ private
38
+
39
+ def decode_labeled
40
+ encodings = content_encodings
30
41
  return if encodings.empty?
31
42
 
32
- encodings.reverse.reduce(raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
43
+ encodings.reverse.reduce(@raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
33
44
  end
34
- module_function :decode_labeled
35
- private_class_method :decode_labeled
36
45
 
37
- def decode_unlabeled(raw, headers)
38
- return uncompress_gzip(raw) if raw.start_with?(GZIP_MAGIC)
39
- return unless octet_stream?(headers)
46
+ def decode_unlabeled
47
+ return uncompress_gzip(@raw) if @raw.start_with?(GZIP_MAGIC)
48
+ return unless octet_stream?
40
49
 
41
- try_brotli(raw)
50
+ try_brotli(@raw)
42
51
  end
43
- module_function :decode_unlabeled
44
- private_class_method :decode_unlabeled
45
52
 
46
53
  def inflate(raw, encoding)
47
54
  case encoding
48
- when 'gzip'
49
- return raw unless raw.start_with?(GZIP_MAGIC)
50
-
51
- uncompress_gzip(raw)
55
+ when 'gzip' then uncompress_gzip(raw)
52
56
  when 'br' then try_brotli(raw, require_html: false)
53
57
  when 'deflate' then inflate_deflate(raw)
54
58
  end
55
59
  rescue Zlib::Error, Brotli::Error, ArgumentError
56
60
  nil
57
61
  end
58
- module_function :inflate
59
- private_class_method :inflate
60
62
 
61
63
  def inflate_deflate(raw)
62
64
  inflater = nil
@@ -67,51 +69,37 @@ module Html2rss
67
69
  ensure
68
70
  inflater&.close
69
71
  end
70
- module_function :inflate_deflate
71
- private_class_method :inflate_deflate
72
72
 
73
73
  def uncompress_gzip(raw)
74
- Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
74
+ Zlib::GzipReader.wrap(StringIO.new(raw), &:read).b
75
75
  rescue Zlib::Error
76
76
  nil
77
77
  end
78
- module_function :uncompress_gzip
79
- private_class_method :uncompress_gzip
80
78
 
81
79
  def try_brotli(raw, require_html: true)
82
80
  inflated = Brotli.inflate(raw)
83
81
  return inflated unless require_html
84
- return inflated if inflated.b.match?(HTML_BODY_SNIFF)
85
82
 
86
- nil
83
+ inflated.b.match?(HTML_BODY_SNIFF) ? inflated : nil
87
84
  rescue Brotli::Error, ArgumentError
88
85
  nil
89
86
  end
90
- module_function :try_brotli
91
- private_class_method :try_brotli
92
87
 
93
- def content_encodings(headers)
94
- header(headers, 'content-encoding')
88
+ def content_encodings
89
+ header('content-encoding')
95
90
  .to_s
96
91
  .split(',')
97
- .filter_map { |val| (s = val.strip.downcase).empty? ? nil : s }
92
+ .map { |val| val.strip.downcase }
93
+ .reject(&:empty?)
98
94
  end
99
- module_function :content_encodings
100
- private_class_method :content_encodings
101
95
 
102
- def octet_stream?(headers)
103
- header(headers, 'content-type').to_s.downcase.include?('application/octet-stream')
96
+ def octet_stream?
97
+ header('content-type').to_s.downcase.include?('application/octet-stream')
104
98
  end
105
- module_function :octet_stream?
106
- private_class_method :octet_stream?
107
99
 
108
- def header(headers, name)
109
- headers.fetch(name) do
110
- headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
111
- end
100
+ def header(name)
101
+ @headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
112
102
  end
113
- module_function :header
114
- private_class_method :header
115
103
  end
116
104
  end
117
105
  end
@@ -6,6 +6,9 @@ module Html2rss
6
6
  # Holds information needed to send requests to websites.
7
7
  # To be passed down to the RequestService's strategies.
8
8
  class Context
9
+ # Raised when a context is built without its runtime policy or request budget.
10
+ class InvalidError < Html2rss::ContractError; end
11
+
9
12
  ##
10
13
  # @param url [String, Html2rss::Url] the URL to request
11
14
  # @param headers [Hash] HTTP request headers
@@ -15,7 +18,8 @@ module Html2rss
15
18
  # @option request_options [String, Html2rss::Url, nil] :origin_url originating URL for same-origin checks
16
19
  # @option request_options [Policy] :policy runtime request policy
17
20
  # @option request_options [Budget] :budget shared request budget for the feed build
18
- # @raise [ArgumentError] if policy or budget is explicitly nil
21
+ # @option request_options [FeedAdvertisement, nil] :advertisement page proof for an +:advertised_feed+ follow-up
22
+ # @raise [InvalidError] if policy or budget is nil or budget is missing
19
23
  def initialize(url:, headers: {}, request: {}, **request_options)
20
24
  @url = Html2rss::Url.from_absolute(url)
21
25
  @headers = normalize_headers(headers).freeze
@@ -44,23 +48,19 @@ module Html2rss
44
48
  # @return [Budget] the shared request budget
45
49
  attr_reader :budget
46
50
 
51
+ # @return [FeedAdvertisement, nil] the page's advertised feeds, for an +:advertised_feed+ follow-up
52
+ attr_reader :advertisement
53
+
47
54
  ##
48
55
  # Builds a follow-up request context sharing headers, budget, and policy.
49
56
  #
50
57
  # @param url [String, Html2rss::Url] the follow-up URL
51
58
  # @param relation [Symbol] why the follow-up is being made
52
59
  # @param origin_url [String, Html2rss::Url] effective origin for same-origin checks
60
+ # @param advertisement [FeedAdvertisement, nil] page proof for an +:advertised_feed+ follow-up
53
61
  # @return [Context] derived request context
54
- def follow_up(url:, relation:, origin_url: self.origin_url)
55
- self.class.new(
56
- url:,
57
- headers:,
58
- request:,
59
- relation:,
60
- origin_url:,
61
- policy:,
62
- budget:
63
- )
62
+ def follow_up(url:, relation:, origin_url: self.origin_url, advertisement: nil)
63
+ self.class.new(url:, headers:, request:, relation:, origin_url:, policy:, budget:, advertisement:)
64
64
  end
65
65
 
66
66
  private
@@ -68,11 +68,12 @@ module Html2rss
68
68
  def assign_request_options(request_options)
69
69
  @relation = request_options.fetch(:relation, :initial)
70
70
  @policy = request_options.fetch(:policy, Policy.default)
71
- raise ArgumentError, 'policy must not be nil' if @policy.nil?
71
+ raise InvalidError, 'policy must not be nil' if @policy.nil?
72
72
 
73
73
  @origin_url = normalized_origin_url(request_options[:origin_url])
74
- @budget = request_options.fetch(:budget) { raise ArgumentError, 'budget is required' }
75
- raise ArgumentError, 'budget must not be nil' if @budget.nil?
74
+ @advertisement = request_options[:advertisement]
75
+ @budget = request_options.fetch(:budget) { raise InvalidError, 'budget is required' }
76
+ raise InvalidError, 'budget must not be nil' if @budget.nil?
76
77
  end
77
78
 
78
79
  def normalized_origin_url(origin_url)
@@ -87,9 +88,7 @@ module Html2rss
87
88
  end
88
89
 
89
90
  def normalize_request(request)
90
- normalized = HashUtil.deep_symbolize_keys(request, context: 'request')
91
- HashUtil.assert_symbol_keys!(normalized, context: 'request')
92
- normalized
91
+ HashUtil.deep_symbolize_keys(request, context: 'request')
93
92
  end
94
93
  end
95
94
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ ##
6
+ # One host/egress-scoped view onto the {CookieStore}: replay, refresh and evict with count-only telemetry.
7
+ # Cookie names and values are session credentials and never reach logs.
8
+ class CookieJar
9
+ # Share of replayable requests that deliberately skip replay, so the pass-rate effect stays measurable.
10
+ HOLDOUT_RATE = Float(ENV.fetch('BOTASAURUS_COOKIE_HOLDOUT_RATE', 0.1))
11
+ # Shared across jars so blocks accumulate per key within the process.
12
+ BREAKER = ReplayBreaker.new
13
+
14
+ ##
15
+ # @param host [String] target host
16
+ # @param scraper_base_url [String] scrape-api base URL
17
+ # @param proxy [String, nil] egress proxy
18
+ # @param user_agent [String, nil] browser user agent
19
+ # @param store [#fetch, #write, #evict] the configured cookie store
20
+ def initialize(host:, scraper_base_url:, proxy:, user_agent:, store: RequestService.cookie_store)
21
+ @store = store
22
+ @host = host
23
+ @key = CookieStore.key(host:, scraper_base_url:, proxy:, user_agent:)
24
+ end
25
+
26
+ # @return [Hash{String => String}] live stored cookies ({} when none)
27
+ def stored = store.fetch(key)
28
+
29
+ ##
30
+ # @param stored [Hash{String => String}] cookies in the jar
31
+ # @return [Boolean] whether to replay them (not when empty, the breaker is open, or in the holdout)
32
+ def replayable?(stored)
33
+ return false if stored.empty?
34
+
35
+ reason = skip_reason
36
+ log(reason, 0) if reason
37
+ reason.nil?
38
+ end
39
+
40
+ ##
41
+ # Records what a replay led to: a pass resets the breaker; a block evicts the jar and counts toward it.
42
+ #
43
+ # @param outcome [Symbol] +:pass+ or +:block+
44
+ # @param stored [Hash{String => String}] cookies that were replayed
45
+ # @return [void]
46
+ def replayed(outcome, stored)
47
+ log("replay_#{outcome}", stored.size)
48
+ return BREAKER.record_pass(key) if outcome == :pass
49
+
50
+ evict(stored)
51
+ log('breaker_open', 0) if BREAKER.record_block(key, ttl: CookieStore::DEFAULT_TTL_SECONDS)
52
+ end
53
+
54
+ ##
55
+ # @param stored [Hash{String => String}] cookies that were replayed
56
+ # @param earned [Hash{String => String}] cookies returned by the scrape
57
+ # @return [void]
58
+ def refresh(stored, earned)
59
+ ttl = CookieStore::DEFAULT_TTL_SECONDS
60
+ return if earned.empty? || ttl <= 0
61
+
62
+ store.write(key, stored.merge(earned), ttl:)
63
+ log('refresh', earned.size)
64
+ end
65
+
66
+ ##
67
+ # @param stored [Hash{String => String}] cookies that were rejected
68
+ # @return [void]
69
+ def evict(stored)
70
+ store.evict(key)
71
+ log('evict', stored.size)
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :store, :key, :host
77
+
78
+ def skip_reason
79
+ return 'breaker_open' if BREAKER.open?(key)
80
+
81
+ 'holdout' if rand < HOLDOUT_RATE
82
+ end
83
+
84
+ def log(action, count)
85
+ Log.info({ event_name: 'botasaurus.cookie_jar', action:, host:, cookie_count: count }.to_json)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -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,12 +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
28
- raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
27
+ raise InvalidOption, 'Local file path is required for local_file strategy' unless file_path
29
28
 
30
29
  body = File.read(file_path)
31
30
  Response.new(
@@ -68,7 +68,7 @@ module Html2rss
68
68
  def validate_remote_ip!(ip:, url:)
69
69
  return if allow_private_networks?
70
70
 
71
- parsed_ip = ip && !ip.to_s.empty? ? parse_ip(ip) : nil
71
+ parsed_ip = parse_ip(ip.to_s)
72
72
  raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
73
73
  return unless blocked_ip?(parsed_ip)
74
74
 
@@ -122,7 +122,8 @@ module Html2rss
122
122
  end
123
123
 
124
124
  def blocked_ip?(address)
125
- BLOCKED_IP_RANGES.any? { |range| range.include?(address) }
125
+ native = address.native
126
+ BLOCKED_IP_RANGES.any? { |range| range.include?(native) }
126
127
  end
127
128
  end
128
129
  end