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
@@ -8,12 +8,15 @@ module Html2rss
8
8
  #
9
9
  # Config does not know MCP. YAML parsing stays on {Html2rss::Config.from_yaml}.
10
10
  class ConfigArgument
11
+ # Raised unless exactly one of +config+ or +yaml+ is present.
12
+ class ExclusiveInputError < Html2rss::InvalidOption; end
13
+
11
14
  class << self
12
15
  ##
13
16
  # @param config [Hash, nil] feed configuration hash
14
17
  # @param yaml [String, nil] feed configuration YAML
15
18
  # @return [ConfigArgument]
16
- # @raise [ArgumentError] unless exactly one of +config+ or +yaml+ is present
19
+ # @raise [ExclusiveInputError] unless exactly one of +config+ or +yaml+ is present
17
20
  # @raise [Html2rss::InvalidOption] when +config+ is not a Hash or +yaml+ is not a String
18
21
  # @raise [Contract::UnpublishedRequestError] when the parsed config uses
19
22
  # an unpublished MCP strategy or +request.local_file_path+
@@ -24,7 +27,7 @@ module Html2rss
24
27
  in [false, true]
25
28
  Config.from_yaml(require_type!(yaml, String, 'yaml'))
26
29
  else
27
- raise ArgumentError, 'Provide exactly one of config or yaml'
30
+ raise ExclusiveInputError, 'Provide exactly one of config or yaml'
28
31
  end
29
32
  Contract.assert_published_request!(parsed)
30
33
  new(config: parsed)
@@ -16,11 +16,11 @@ module Html2rss
16
16
  ALL_ACCEPTED_STRATEGIES = (STRATEGIES + MIGRATION_STRATEGIES).freeze
17
17
 
18
18
  # Bump when tool names, required inputs, or envelope semantics change (independent of gem +VERSION+).
19
- MCP_CONTRACT_VERSION = 3
19
+ MCP_CONTRACT_VERSION = 4
20
20
  public_constant :MCP_CONTRACT_VERSION
21
21
 
22
22
  # Raised when apply/validate config uses an unpublished MCP request adapter.
23
- class UnpublishedRequestError < ArgumentError; end
23
+ class UnpublishedRequestError < Html2rss::InvalidOption; end
24
24
 
25
25
  # JSON Schema property for a source page URL.
26
26
  URL_PROPERTY = {
@@ -244,7 +244,14 @@ module Html2rss
244
244
  ok: { type: 'boolean' },
245
245
  next_step: { type: 'string', enum: Outcome::NextStep::ALL.keys.map(&:to_s) },
246
246
  guidance: { type: 'string' },
247
- payload: { type: 'object' }
247
+ payload: { type: 'object' },
248
+ cache: {
249
+ type: 'object',
250
+ description: 'Present when part of this result came from the response cache instead of the live page',
251
+ additionalProperties: false,
252
+ required: %w[hits max_age_seconds],
253
+ properties: { hits: { type: 'integer' }, max_age_seconds: { type: 'integer' } }
254
+ }
248
255
  }
249
256
  }
250
257
  end
@@ -253,9 +260,11 @@ module Html2rss
253
260
  # One envelope Hash, one compact JSON body, no +_meta+.
254
261
  #
255
262
  # @param outcome [Outcome]
263
+ # @param cache_ages [Array<Integer>] ages in seconds of cached page fetches behind this result
256
264
  # @return [::MCP::Tool::Response]
257
- def response(outcome)
265
+ def response(outcome, cache_ages: [])
258
266
  wire = outcome.to_h
267
+ wire[:cache] = { hits: cache_ages.size, max_age_seconds: cache_ages.max } unless cache_ages.empty?
259
268
  ::MCP::Tool::Response.new(
260
269
  [{ type: 'text', text: JSON.generate(wire) }],
261
270
  error: !outcome.ok,
@@ -8,8 +8,9 @@ module Html2rss
8
8
  # Typed MCP tool result. Owns next-step policy; guidance copy lives in
9
9
  # {Playbook}.
10
10
  class Outcome # rubocop:disable Metrics/ClassLength -- next-step policy + factories stay co-located
11
- # Matches {ConfigArgument} XOR +ArgumentError+ messages.
12
- XOR_ERROR = /exactly one of config or yaml/
11
+ # Raised when an outcome is built with a wrong-typed next step or payload.
12
+ class InvalidError < Html2rss::ContractError; end
13
+
13
14
  NextStep = Data.define(:name, :guidance)
14
15
 
15
16
  ##
@@ -22,7 +23,7 @@ module Html2rss
22
23
  ##
23
24
  # @param name [Symbol, String]
24
25
  # @return [NextStep]
25
- def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown next_step: #{name.inspect}" }
26
+ def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown next_step: #{name.inspect}" }
26
27
  end
27
28
 
28
29
  ##
@@ -31,8 +32,8 @@ module Html2rss
31
32
  # @param guidance [String]
32
33
  # @param payload [Hash]
33
34
  def initialize(ok:, next_step:, guidance:, payload:) # rubocop:disable Naming/MethodParameterName -- +ok+ is the envelope field
34
- raise ArgumentError, 'next_step must be a NextStep' unless next_step.is_a?(NextStep)
35
- raise ArgumentError, 'payload must be a Hash' unless payload.is_a?(Hash)
35
+ raise InvalidError, 'next_step must be a NextStep' unless next_step.is_a?(NextStep)
36
+ raise InvalidError, 'payload must be a Hash' unless payload.is_a?(Hash)
36
37
 
37
38
  super(ok: !!ok, next_step:, guidance: guidance.to_s.freeze, payload: payload.dup.freeze)
38
39
  end
@@ -218,7 +219,13 @@ module Html2rss
218
219
  warnings = test_result.quality_report&.warnings
219
220
  return base if warnings.nil? || warnings.empty?
220
221
 
221
- "#{base} Review payload.quality_report warnings: #{warnings.join(', ')}."
222
+ "#{base} Review payload.quality_report warnings: #{warnings.join(', ')}.#{items_without_url_hint(warnings)}"
223
+ end
224
+
225
+ def items_without_url_hint(warnings)
226
+ return '' unless warnings.include?(:items_without_url)
227
+
228
+ ' Items were dropped for having no URL: fix the selectors.url selector (it must match a link on each item).'
222
229
  end
223
230
 
224
231
  def capture_payload(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
@@ -237,15 +244,10 @@ module Html2rss
237
244
  def next_step_for_error(error)
238
245
  case error
239
246
  when RequestService::BotasaurusConfigurationError then NextStep[:read_runtime]
240
- when Contract::UnpublishedRequestError then NextStep[:validate]
241
- when ArgumentError then argument_error_next_step(error)
247
+ when Contract::UnpublishedRequestError, ConfigArgument::ExclusiveInputError then NextStep[:validate]
242
248
  else NextStep[:inspect]
243
249
  end
244
250
  end
245
-
246
- def argument_error_next_step(error)
247
- XOR_ERROR.match?(error.message) ? NextStep[:validate] : NextStep[:inspect]
248
- end
249
251
  end
250
252
  end
251
253
  end
@@ -11,9 +11,16 @@ module Html2rss
11
11
  :mcp_contract_version,
12
12
  :catalog_fingerprint,
13
13
  :tools,
14
- :botasaurus_configured
14
+ :botasaurus_configured,
15
+ :response_cache_ttl
15
16
  )
16
17
 
18
+ # Seconds an initial page fetch is reused when +HTML2RSS_RESPONSE_CACHE_TTL+ is unset.
19
+ DEFAULT_RESPONSE_CACHE_TTL = 300
20
+ # Values of +HTML2RSS_RESPONSE_CACHE_TTL+ that disable the cache.
21
+ CACHE_OFF_VALUES = %w[0 off false].freeze
22
+ private_constant :CACHE_OFF_VALUES
23
+
17
24
  class << self
18
25
  ##
19
26
  # @return [Boolean] whether Botasaurus transport is configured in this process
@@ -21,6 +28,18 @@ module Html2rss
21
28
  !ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
22
29
  end
23
30
 
31
+ ##
32
+ # Installs the initial-fetch cache for the long-lived MCP process, so an agent iterating
33
+ # +inspect -> capture -> validate -> test+ on one URL fetches it once per TTL window.
34
+ # +HTML2RSS_RESPONSE_CACHE_TTL+ sets the seconds ({DEFAULT_RESPONSE_CACHE_TTL} when unset);
35
+ # +0+, +off+ or +false+ disables caching. An unusable value logs a warning and uses the default.
36
+ #
37
+ # @return [void]
38
+ def install_response_cache!
39
+ ttl = response_cache_ttl
40
+ RequestService.response_cache = ttl && RequestService::ResponseCache.new(ttl:)
41
+ end
42
+
24
43
  ##
25
44
  # @return [Snapshot] runtime capabilities and catalog identity for MCP clients
26
45
  def snapshot
@@ -29,9 +48,27 @@ module Html2rss
29
48
  mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
30
49
  catalog_fingerprint: Contract.catalog_fingerprint,
31
50
  tools: Contract.catalog_tools,
32
- botasaurus_configured: botasaurus_configured?
51
+ botasaurus_configured: botasaurus_configured?,
52
+ response_cache_ttl: RequestService.response_cache&.ttl
33
53
  )
34
54
  end
55
+
56
+ private
57
+
58
+ def response_cache_ttl
59
+ raw = ENV['HTML2RSS_RESPONSE_CACHE_TTL'].to_s.strip.downcase
60
+ return DEFAULT_RESPONSE_CACHE_TTL if raw.empty?
61
+ return if CACHE_OFF_VALUES.include?(raw)
62
+
63
+ ttl = Integer(raw, 10, exception: false)
64
+ ttl&.positive? ? ttl : invalid_cache_ttl(raw)
65
+ end
66
+
67
+ def invalid_cache_ttl(raw)
68
+ Log.warn("HTML2RSS_RESPONSE_CACHE_TTL=#{raw.inspect} is not a positive integer; " \
69
+ "using #{DEFAULT_RESPONSE_CACHE_TTL}s")
70
+ DEFAULT_RESPONSE_CACHE_TTL
71
+ end
35
72
  end
36
73
  end
37
74
  end
@@ -23,6 +23,9 @@ module Html2rss
23
23
  # Supported transport names.
24
24
  TRANSPORTS = Set[:stdio, :http].freeze
25
25
 
26
+ # Raised when the HTTP transport's optional gems (+rackup+, +webrick+) cannot be loaded.
27
+ class HttpTransportUnavailable < Html2rss::Error; end
28
+
26
29
  # Declarative MCP resource registrations consumed by {register_resources}.
27
30
  RESOURCES = [
28
31
  {
@@ -95,10 +98,13 @@ module Html2rss
95
98
  #
96
99
  # @param transport [Symbol] +:stdio+ or +:http+
97
100
  # @param port [Integer] port for HTTP transport
101
+ # @raise [InvalidOption] for an unknown transport
102
+ # @raise [HttpTransportUnavailable] when +:http+ is requested without the rackup and webrick gems
98
103
  def start(transport: :stdio, port: 8080)
99
- raise ArgumentError, "Unknown transport: #{transport.inspect}" unless TRANSPORTS.include?(transport)
104
+ raise InvalidOption, "Unknown transport: #{transport.inspect}" unless TRANSPORTS.include?(transport)
100
105
 
101
106
  configure_daemon_logging!
107
+ Runtime.install_response_cache!
102
108
  app = build
103
109
  Log.info(start_banner(transport:, port:))
104
110
  return start_http(app, port:) if transport == :http
@@ -204,7 +210,7 @@ module Html2rss
204
210
  Logger: Html2rss.logger
205
211
  )
206
212
  rescue LoadError => error
207
- raise LoadError,
213
+ raise HttpTransportUnavailable,
208
214
  'HTTP transport requires the rackup and webrick gems ' \
209
215
  "(#{error.message}). Install them or use --transport stdio."
210
216
  end
@@ -218,8 +224,11 @@ module Html2rss
218
224
  Contract.response(Outcome.from_error(error))
219
225
  end
220
226
 
221
- def handle_tool_call
222
- Contract.response(yield)
227
+ def handle_tool_call(&)
228
+ outcome, cache_ages = RequestService.observe_cache_hits(&)
229
+ Contract.response(outcome, cache_ages:)
230
+ rescue Html2rss::ContractError
231
+ raise
223
232
  rescue StandardError => error
224
233
  tool_error_response(error)
225
234
  end
@@ -10,7 +10,7 @@
10
10
  | `final_url` | URL after redirects (may differ from requested) |
11
11
  | `status` | HTTP status of the **final** response |
12
12
  | `scheme_downgrade` | `true` when HTTPS entry landed on HTTP |
13
- | `alternate_feeds` | `rel=alternate` RSS/Atom links found in HTML |
13
+ | `alternate_feeds` | `rel=alternate` RSS/Atom/RDF links found in HTML |
14
14
  | `surface_category` | AutoSource surface class (`high_entropy_surface`, `unsupported_surface`, …) |
15
15
  | `articles_count` | Cheap AutoSource extract (limit 10) — diagnostic only, not ship quality |
16
16
  | `strategy` | Concrete transport used (`inspect` maps `auto` → default (HTTPX)) |
@@ -57,6 +57,8 @@ module Html2rss
57
57
  def self.batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
58
58
  Batch.map(Array(urls), concurrency:) do |url|
59
59
  call(url:, strategy:)
60
+ rescue Html2rss::ContractError
61
+ raise
60
62
  rescue Html2rss::Error => error
61
63
  error_report(url, error)
62
64
  end
@@ -7,7 +7,7 @@ require 'fileutils'
7
7
  module Html2rss
8
8
  ##
9
9
  # Service that runs reconnaissance on a URL to discover redirect chains,
10
- # native RSS/Atom feeds, surface categorization, and emit an actionable verdict.
10
+ # native RSS/Atom/RDF feeds, surface categorization, and emit an actionable verdict.
11
11
  module Recon # rubocop:disable Metrics/ModuleLength
12
12
  Verdict = Data.define(:name)
13
13
 
@@ -21,7 +21,7 @@ module Html2rss
21
21
  ##
22
22
  # @param name [Symbol, String]
23
23
  # @return [Verdict]
24
- def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown verdict: #{name.inspect}" }
24
+ def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown verdict: #{name.inspect}" }
25
25
 
26
26
  ##
27
27
  # @return [Boolean]
@@ -109,6 +109,8 @@ module Html2rss
109
109
 
110
110
  begin
111
111
  probe = PageRecon.probe(url_obj, strategy:, **)
112
+ rescue Html2rss::ContractError
113
+ raise
112
114
  rescue Html2rss::Error => error
113
115
  return error_result(url_obj, error)
114
116
  end
@@ -116,7 +118,7 @@ module Html2rss
116
118
  cache_html_body(probe.response.body, url_obj, cache_dir, cache_mutex) if cache_dir && probe.response.body
117
119
 
118
120
  recon = probe.result
119
- native_feed, discovery_note = find_native_feed(url_obj, probe.session, probe.response)
121
+ native_feed, discovery_note = find_native_feed(probe.session, probe.response)
120
122
  notes = build_notes(recon, native_feed, probe.response)
121
123
  notes << discovery_note if discovery_note
122
124
  verdict = determine_verdict(recon, native_feed)
@@ -160,18 +162,14 @@ module Html2rss
160
162
  end
161
163
 
162
164
  ##
163
- # @param url_obj [Html2rss::Url]
164
165
  # @param session [Html2rss::RequestSession]
165
- # @param response [Html2rss::RequestService::Response]
166
+ # @param response [Html2rss::RequestService::Response] the fetched page, where any redirect landed
166
167
  # @return [Array(Html2rss::Url, nil), String, nil)] feed URL and optional discovery error note
167
- def self.find_native_feed(url_obj, session, response)
168
- feed = Syndication::Discovery.best_feed_url(
169
- page_url: url_obj,
170
- request_session: session,
171
- parsed_body: (response.parsed_body if response.html_response?),
172
- html: response.body
173
- )
168
+ def self.find_native_feed(session, response)
169
+ feed = Syndication::Discovery.native_feed_url(response:, request_session: session)
174
170
  [feed, nil]
171
+ rescue Html2rss::ContractError
172
+ raise
175
173
  rescue Html2rss::Error => error
176
174
  [nil, "discovery_error=#{error.class}: #{error.message}"]
177
175
  end
@@ -34,7 +34,8 @@ module Html2rss
34
34
  /interstitial/i
35
35
  ],
36
36
  message: 'Blocked surface detected: DataDome anti-bot challenge page. ' \
37
- 'Target a direct listing URL, or scrape via a session with resolved DataDome cookies.'
37
+ 'Target a direct listing URL. Clearance cookies from a successful Botasaurus scrape ' \
38
+ 'are reused automatically; to supply your own, set request.botasaurus.cookies.'
38
39
  },
39
40
  {
40
41
  key: :vercel_security_checkpoint,
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # Closed set from OpenAPI ExecutionMode.
12
12
  EXECUTION_MODES = Set['auto', 'request', 'browser'].freeze
13
13
  # Closed set from OpenAPI NavigationMode.
14
- NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass', 'organic_get'].freeze
14
+ NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass'].freeze
15
15
  # Closed set from OpenAPI ErrorCategory.
16
16
  ERROR_CATEGORIES = Set['timeout', 'challenge_block', 'navigation_error', 'metadata_error', 'validation'].freeze
17
17
 
@@ -84,6 +84,15 @@ module Html2rss
84
84
  # @return [String, nil] final URL reported by upstream
85
85
  def final_url = payload['final_url']
86
86
 
87
+ # Session credentials: keep out of logs, transport_meta and feed output.
88
+ # @return [Hash{String => String}] cookies earned for the target host (absent or malformed → {})
89
+ def cookies
90
+ raw = payload['cookies']
91
+ return {} unless raw.is_a?(Hash)
92
+
93
+ raw.select { |name, value| name.is_a?(String) && value.is_a?(String) }
94
+ end
95
+
87
96
  # @return [Hash{String => Object}] diagnostics plus success-only metadata_error (frozen)
88
97
  def transport_meta
89
98
  meta = diagnostics.dup
@@ -258,6 +267,7 @@ module Html2rss
258
267
  # @param url [Html2rss::Url] canonical URL to scrape
259
268
  # @param headers [Hash] request headers from context
260
269
  # @param options [Hash] validated request.botasaurus options
270
+ # @param stored_cookies [Hash{String => String}] cookies replayed from the cookie store
261
271
  # @option options [String] :execution_mode
262
272
  # @option options [String] :navigation_mode
263
273
  # @option options [Integer] :max_retries
@@ -275,18 +285,23 @@ module Html2rss
275
285
  # @option options [String] :lang
276
286
  # @option options [Hash] :cookies
277
287
  # @option options [Hash] :headers
278
- def initialize(url:, headers: {}, options: {})
288
+ def initialize(url:, headers: {}, options: {}, stored_cookies: {})
279
289
  @url = url
280
290
  @headers = headers
281
291
  @options = options
292
+ @stored_cookies = stored_cookies
282
293
  end
283
294
 
295
+ # @return [String] omits options and stored cookies (session credentials)
296
+ def inspect = "#<#{self.class} url=#{url}>"
297
+
284
298
  # @return [Hash] payload for POST /scrape (explicit options plus wait cap)
285
299
  def request_payload
286
300
  payload = { url: url.to_s }.merge(filtered_options)
287
301
  forwarded_headers = merged_headers
288
302
  payload[:headers] = forwarded_headers if forwarded_headers&.any?
289
303
  cap_wait_timeout!(payload)
304
+ merge_cookies!(payload)
290
305
  payload
291
306
  end
292
307
 
@@ -302,7 +317,7 @@ module Html2rss
302
317
 
303
318
  private
304
319
 
305
- attr_reader :url, :headers, :options
320
+ attr_reader :url, :headers, :options, :stored_cookies
306
321
 
307
322
  def json_object(body)
308
323
  payload = JSON.parse(body)
@@ -326,6 +341,13 @@ module Html2rss
326
341
  end
327
342
  end
328
343
 
344
+ # Stored cookies are replayed in every execution_mode; cookies from the feed config win.
345
+ def merge_cookies!(payload)
346
+ configured = (options[:cookies] || {}).transform_keys(&:to_s)
347
+ merged = stored_cookies.merge(configured)
348
+ merged.any? ? payload[:cookies] = merged : payload.delete(:cookies)
349
+ end
350
+
329
351
  def cap_wait_timeout!(payload)
330
352
  wait = payload[:wait_timeout_seconds]
331
353
  return unless wait
@@ -8,7 +8,7 @@ module Html2rss
8
8
  class RequestService
9
9
  ##
10
10
  # Strategy to delegate fetching to a Botasaurus scrape API.
11
- class BotasaurusStrategy < Strategy
11
+ class BotasaurusStrategy < Strategy # rubocop:disable Metrics/ClassLength -- transport + cookie-jar retry stay co-located
12
12
  # Content-Type negotiation for the scrape API transport hop.
13
13
  TRANSPORT_ACCEPT = 'application/json'
14
14
  # Disable compressed bodies so HTTPX returns raw JSON without implicit decoding surprises.
@@ -18,16 +18,49 @@ module Html2rss
18
18
 
19
19
  private
20
20
 
21
+ # With a stored jar, tries the cheap HTTP tier first; cookies never reach the browser tier,
22
+ # so a block falls back to a plain scrape without them.
21
23
  def fetch
22
- parsed_response = post_scrape_request
24
+ stored = jar.stored
25
+ return scrape({}) unless replay?(stored)
26
+
27
+ replay(stored)
28
+ end
29
+
30
+ def replay?(stored) = botasaurus_options[:execution_mode] != 'browser' && jar.replayable?(stored)
31
+
32
+ def replay(stored)
33
+ response = scrape(stored, execution_mode: 'request')
34
+ jar.replayed(:pass, stored)
35
+ response
36
+ rescue BlockedSurfaceDetected
37
+ jar.replayed(:block, stored)
38
+ scrape({})
39
+ end
40
+
41
+ def scrape(stored, overrides = {})
42
+ parsed_response = post_scrape_request(stored, overrides)
23
43
  raise_from_error!(parsed_response) if parsed_response.is_a?(BotasaurusContract::Error)
24
44
 
25
- build_response(parsed_response)
45
+ response = build_response(parsed_response)
46
+ ResponseGuard.new(policy: ctx.policy).inspect_body!(response.body)
47
+ jar.refresh(stored, response.cookies)
48
+ response
26
49
  end
27
50
 
28
- def post_scrape_request
51
+ def jar
52
+ @jar ||= CookieJar.new(
53
+ host: ctx.url.host, scraper_base_url: scraper_base_url.to_s, proxy: botasaurus_options[:proxy],
54
+ user_agent: botasaurus_options[:user_agent] || ctx.headers['User-Agent']
55
+ )
56
+ end
57
+
58
+ def botasaurus_options = ctx.request.fetch(:botasaurus, {})
59
+
60
+ def post_scrape_request(stored, overrides)
29
61
  request_id = SecureRandom.uuid
30
62
  Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
63
+ contract = contract(stored, overrides)
31
64
  payload = JSON.generate(contract.request_payload)
32
65
  response = client.post(scrape_endpoint, body: payload, headers: post_headers(request_id))
33
66
  raise response.error if response.is_a?(HTTPX::ErrorResponse)
@@ -44,7 +77,8 @@ module Html2rss
44
77
  url: response_url(parsed_response.final_url),
45
78
  status: parsed_response.status,
46
79
  transport_meta: parsed_response.transport_meta,
47
- captured_responses: parsed_response.xhr_responses
80
+ captured_responses: parsed_response.xhr_responses,
81
+ cookies: parsed_response.cookies
48
82
  )
49
83
  end
50
84
 
@@ -69,11 +103,9 @@ module Html2rss
69
103
  ctx.url
70
104
  end
71
105
 
72
- def contract
73
- @contract ||= BotasaurusContract.new(
74
- url: ctx.url,
75
- headers: ctx.headers,
76
- options: ctx.request.fetch(:botasaurus, {})
106
+ def contract(stored, overrides)
107
+ BotasaurusContract.new(
108
+ url: ctx.url, headers: ctx.headers, options: botasaurus_options.merge(overrides), stored_cookies: stored
77
109
  )
78
110
  end
79
111
 
@@ -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)
@@ -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