html2rss 0.30.0 → 0.31.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +1 -1
  6. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  10. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  11. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  13. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  14. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  16. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  17. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper.rb +4 -14
  19. data/lib/html2rss/auto_source/segment.rb +7 -4
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  21. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  22. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  23. data/lib/html2rss/auto_source.rb +2 -2
  24. data/lib/html2rss/batch.rb +3 -1
  25. data/lib/html2rss/capture/README.md +6 -4
  26. data/lib/html2rss/capture.rb +67 -35
  27. data/lib/html2rss/channel.rb +3 -3
  28. data/lib/html2rss/cli/probe_view.rb +5 -3
  29. data/lib/html2rss/cli/render.rb +6 -5
  30. data/lib/html2rss/cli/validate.rb +28 -27
  31. data/lib/html2rss/cli.rb +1 -1
  32. data/lib/html2rss/config/issue_mapper.rb +108 -0
  33. data/lib/html2rss/config/request_headers.rb +2 -3
  34. data/lib/html2rss/config/schema.rb +16 -1
  35. data/lib/html2rss/config/selectors_validator.rb +92 -70
  36. data/lib/html2rss/config/validation_issue.rb +60 -0
  37. data/lib/html2rss/config/validation_report.rb +55 -0
  38. data/lib/html2rss/config/validator.rb +10 -6
  39. data/lib/html2rss/config.rb +35 -113
  40. data/lib/html2rss/defaults.rb +42 -30
  41. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  42. data/lib/html2rss/doctor.rb +8 -0
  43. data/lib/html2rss/error.rb +1 -1
  44. data/lib/html2rss/feed_builder/rss.rb +9 -14
  45. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  46. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  48. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  49. data/lib/html2rss/feed_resolution/options.rb +3 -1
  50. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  51. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  52. data/lib/html2rss/html/article_extractor.rb +2 -4
  53. data/lib/html2rss/html/feed_link.rb +3 -1
  54. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  55. data/lib/html2rss/html/navigator.rb +10 -10
  56. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  57. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  58. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  59. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  60. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  61. data/lib/html2rss/mcp/README.md +2 -0
  62. data/lib/html2rss/mcp/config_argument.rb +3 -1
  63. data/lib/html2rss/mcp/contract.rb +6 -20
  64. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  65. data/lib/html2rss/mcp/outcome.rb +36 -43
  66. data/lib/html2rss/mcp/server/tools.rb +111 -131
  67. data/lib/html2rss/mcp/server.rb +26 -18
  68. data/lib/html2rss/page_recon/README.md +5 -5
  69. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  70. data/lib/html2rss/page_recon.rb +9 -5
  71. data/lib/html2rss/recon.rb +19 -30
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  74. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  75. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  76. data/lib/html2rss/request_service/network_guard.rb +7 -5
  77. data/lib/html2rss/request_service/response.rb +1 -2
  78. data/lib/html2rss/request_service/strategy.rb +3 -8
  79. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  80. data/lib/html2rss/scoring/engine.rb +8 -8
  81. data/lib/html2rss/scoring/observation.rb +5 -3
  82. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  83. data/lib/html2rss/scrape_target.rb +3 -1
  84. data/lib/html2rss/selector_candidates.rb +234 -0
  85. data/lib/html2rss/selectors/README.md +64 -0
  86. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  87. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  88. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  89. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  90. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  91. data/lib/html2rss/selectors/extractors.rb +33 -20
  92. data/lib/html2rss/selectors/item_env.rb +31 -0
  93. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  94. data/lib/html2rss/selectors/option_spec.rb +140 -0
  95. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  96. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  97. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  98. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  99. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  100. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  101. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  102. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  103. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  104. data/lib/html2rss/selectors/post_processors.rb +6 -5
  105. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  106. data/lib/html2rss/selectors/step_env.rb +24 -0
  107. data/lib/html2rss/selectors.rb +231 -142
  108. data/lib/html2rss/sst/attrs.rb +5 -3
  109. data/lib/html2rss/sst/document.rb +3 -1
  110. data/lib/html2rss/sst/index.rb +3 -1
  111. data/lib/html2rss/sst/node.rb +29 -10
  112. data/lib/html2rss/sst/normalizer.rb +12 -16
  113. data/lib/html2rss/sst/tags.rb +7 -7
  114. data/lib/html2rss/status.rb +13 -8
  115. data/lib/html2rss/surface_category.rb +14 -32
  116. data/lib/html2rss/syndication/parser.rb +2 -2
  117. data/lib/html2rss/test/enhance_audit.rb +1 -3
  118. data/lib/html2rss/test/policy.rb +106 -0
  119. data/lib/html2rss/test.rb +126 -173
  120. data/lib/html2rss/url.rb +25 -4
  121. data/lib/html2rss/version.rb +1 -1
  122. data/lib/html2rss.rb +21 -13
  123. data/lib/tasks/config_schema.rake +2 -2
  124. data/schema/html2rss-config.schema.json +11 -8
  125. metadata +15 -6
  126. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -8,12 +8,14 @@ module Html2rss
8
8
  # article count. Diagnostic fetch for Inspect/Recon lives on {.probe} — not twin
9
9
  # fetch helpers in those callers.
10
10
  class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
11
+ Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response)
12
+
11
13
  ##
12
14
  # Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
13
- Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response) do
15
+ class Assessment
14
16
  ##
15
17
  # @return [Html2rss::SurfaceCategory]
16
- def category = SurfaceCategory.coerce(surface_category)
18
+ def category = SurfaceCategory[surface_category]
17
19
 
18
20
  ##
19
21
  # @return [Boolean]
@@ -27,8 +29,6 @@ module Html2rss
27
29
  # @return [Boolean]
28
30
  def listing_bonus? = category.listing_bonus?
29
31
  end
30
- ##
31
- # Recon facts used by Inspect and FeedResolution.
32
32
  Result = Data.define(
33
33
  :requested_url,
34
34
  :final_url,
@@ -43,7 +43,11 @@ module Html2rss
43
43
  :content_type,
44
44
  :blocked_surface,
45
45
  :sst
46
- ) do
46
+ )
47
+
48
+ ##
49
+ # Recon facts used by Inspect and FeedResolution.
50
+ class Result
47
51
  ##
48
52
  # @return [Hash{Symbol => Object}]
49
53
  def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- omit-empty optional keys
@@ -16,27 +16,14 @@ module Html2rss
16
16
  ##
17
17
  # Closed curation verdict (:build / :defer / :drop).
18
18
  class Verdict
19
- # Closed set of curation verdict wire names.
20
- NAMES = Set[:build, :defer, :drop].freeze
21
-
22
- class << self
23
- ##
24
- # @param value [Verdict, Symbol, String]
25
- # @return [Verdict]
26
- def coerce(value)
27
- return value if value.is_a?(self)
28
-
29
- new(name: value.to_sym)
30
- end
31
- end
19
+ # Frozen name → instance table.
20
+ ALL = %i[build defer drop].to_h { |name| [name, new(name:)] }.freeze
21
+ private_class_method :new
32
22
 
33
23
  ##
34
- # @param name [Symbol]
35
- def initialize(name:)
36
- raise ArgumentError, "unknown verdict: #{name.inspect}" unless NAMES.include?(name)
37
-
38
- super
39
- end
24
+ # @param name [Symbol, String]
25
+ # @return [Verdict]
26
+ def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown verdict: #{name.inspect}" }
40
27
 
41
28
  ##
42
29
  # @return [Boolean]
@@ -59,8 +46,6 @@ module Html2rss
59
46
  def to_s = name.to_s
60
47
  end
61
48
 
62
- ##
63
- # Immutable outcome of a reconnaissance operation.
64
49
  Result = Data.define(
65
50
  :requested_url,
66
51
  :final_url,
@@ -72,7 +57,11 @@ module Html2rss
72
57
  :scheme_downgrade,
73
58
  :notes,
74
59
  :html_bytesize
75
- ) do
60
+ )
61
+
62
+ ##
63
+ # Immutable outcome of a reconnaissance operation.
64
+ class Result
76
65
  ##
77
66
  # @return [Boolean]
78
67
  def build? = verdict.build?
@@ -140,7 +129,7 @@ module Html2rss
140
129
  status: probe.response.status,
141
130
  verdict:,
142
131
  native_feed:,
143
- surface_category: SurfaceCategory.coerce(recon.surface_category),
132
+ surface_category: SurfaceCategory[recon.surface_category],
144
133
  articles_count: recon.articles_count,
145
134
  scheme_downgrade: recon.scheme_downgrade,
146
135
  notes:,
@@ -204,13 +193,13 @@ module Html2rss
204
193
  private_class_method :build_notes
205
194
 
206
195
  def determine_verdict(recon, native_feed)
207
- return Verdict.coerce(:drop) if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
208
- return Verdict.coerce(:defer) if native_feed
196
+ return Verdict[:drop] if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
197
+ return Verdict[:defer] if native_feed
209
198
 
210
- category = SurfaceCategory.coerce(recon.surface_category)
211
- return Verdict.coerce(:drop) if category.blocked?
199
+ category = SurfaceCategory[recon.surface_category]
200
+ return Verdict[:drop] if category.blocked?
212
201
 
213
- Verdict.coerce(:build)
202
+ Verdict[:build]
214
203
  end
215
204
  private_class_method :determine_verdict
216
205
 
@@ -219,9 +208,9 @@ module Html2rss
219
208
  requested_url: url_obj,
220
209
  final_url: url_obj,
221
210
  status: nil,
222
- verdict: Verdict.coerce(:drop),
211
+ verdict: Verdict[:drop],
223
212
  native_feed: nil,
224
- surface_category: SurfaceCategory.coerce(:unsupported_surface),
213
+ surface_category: SurfaceCategory[:unsupported_surface],
225
214
  articles_count: 0,
226
215
  scheme_downgrade: false,
227
216
  notes: ["error: #{error.class} - #{error.message}"],
@@ -9,11 +9,11 @@ module Html2rss
9
9
  # +ScrapeRequest+ / +ScrapeSuccess+ / +ScrapeError+ (sibling +openapi.yaml+).
10
10
  class BotasaurusContract
11
11
  # Closed set from OpenAPI ExecutionMode.
12
- EXECUTION_MODES = %w[auto request browser].freeze
12
+ EXECUTION_MODES = Set['auto', 'request', 'browser'].freeze
13
13
  # Closed set from OpenAPI NavigationMode.
14
- NAVIGATION_MODES = %w[auto get google_get google_get_bypass organic_get].freeze
14
+ NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass', 'organic_get'].freeze
15
15
  # Closed set from OpenAPI ErrorCategory.
16
- ERROR_CATEGORIES = %w[timeout challenge_block navigation_error metadata_error validation].freeze
16
+ ERROR_CATEGORIES = Set['timeout', 'challenge_block', 'navigation_error', 'metadata_error', 'validation'].freeze
17
17
 
18
18
  # ScrapeRequest properties except +url+ (html2rss supplies the target URL).
19
19
  REQUEST_OPTION_KEYS = %i[
@@ -25,7 +25,7 @@ module Html2rss
25
25
  # OpenAPI WindowSize required keys (positive integers).
26
26
  WINDOW_SIZE_PROPERTIES = %i[width height].freeze
27
27
 
28
- # Faraday POST /scrape cap mirroring botasaurus-scrape-api total scrape wall (`SCRAPE_TIMEOUT_SECONDS`).
28
+ # HTTPX POST /scrape cap mirroring botasaurus-scrape-api total scrape wall (`SCRAPE_TIMEOUT_SECONDS`).
29
29
  SCRAPE_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_TIMEOUT_SECONDS', 45))
30
30
  # Post-boot navigate/wait budget mirrored from scrape-api (`SCRAPE_WORK_TIMEOUT_SECONDS`).
31
31
  SCRAPE_WORK_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_WORK_TIMEOUT_SECONDS', 30))
@@ -72,10 +72,7 @@ module Html2rss
72
72
 
73
73
  # @return [Hash{String => String}] normalized response headers (null → {})
74
74
  def headers
75
- raw_headers = payload['headers']
76
- return {} unless raw_headers.is_a?(Hash) && raw_headers.any?
77
-
78
- raw_headers.to_h { |key, value| [key.to_s, value.to_s] }
75
+ stringify_headers(payload['headers'])
79
76
  end
80
77
 
81
78
  # @return [Integer, nil] document status_code when present
@@ -147,10 +144,12 @@ module Html2rss
147
144
  entry[key.to_s] || entry[key]
148
145
  end
149
146
 
150
- def xhr_headers(raw)
151
- return {} unless raw.is_a?(Hash)
147
+ def xhr_headers(raw) = stringify_headers(raw)
148
+
149
+ def stringify_headers(raw)
150
+ return {} unless raw.is_a?(Hash) && raw.any?
152
151
 
153
- raw.to_h { |key, value| [key.to_s, value.to_s] }
152
+ raw.transform_keys(&:to_s).transform_values(&:to_s)
154
153
  end
155
154
 
156
155
  def xhr_status_code(value)
@@ -286,7 +285,7 @@ module Html2rss
286
285
  payload
287
286
  end
288
287
 
289
- # @param transport_response [Faraday::Response] upstream HTTP response
288
+ # @param transport_response [HTTPX::Response] upstream HTTP response
290
289
  # @return [Success, Error]
291
290
  # @raise [BotasaurusServiceError] when payload is not a scrape envelope
292
291
  def parse_response(transport_response)
@@ -84,7 +84,7 @@ module Html2rss
84
84
  {
85
85
  operation_timeout: timeout,
86
86
  connect_timeout: [ctx.policy.connect_timeout_seconds, timeout].min,
87
- read_timeout: [ctx.policy.read_timeout_seconds, timeout].min,
87
+ read_timeout: timeout,
88
88
  request_timeout: timeout,
89
89
  total_request_timeout: timeout
90
90
  }
@@ -91,7 +91,10 @@ module Html2rss
91
91
  private_class_method :try_brotli
92
92
 
93
93
  def content_encodings(headers)
94
- header(headers, 'content-encoding').to_s.split(',').map { |value| value.strip.downcase }.reject(&:empty?)
94
+ header(headers, 'content-encoding')
95
+ .to_s
96
+ .split(',')
97
+ .filter_map { |val| (s = val.strip.downcase).empty? ? nil : s }
95
98
  end
96
99
  module_function :content_encodings
97
100
  private_class_method :content_encodings
@@ -5,7 +5,6 @@ require 'httpx/plugins/follow_redirects'
5
5
  require 'httpx/plugins/callbacks'
6
6
  require 'httpx/plugins/ssrf_filter'
7
7
  require 'httpx/plugins/brotli'
8
- require 'httpx/plugins/fiber_concurrency'
9
8
  require 'httpx/plugins/retries'
10
9
 
11
10
  module Html2rss
@@ -16,39 +15,46 @@ module Html2rss
16
15
  # and redirect handling without monkey-patching.
17
16
  # rubocop:disable-next Metrics/ClassLength -- terminal redirect retry colocated with HTTPX transport
18
17
  class HttpxStrategy < Strategy
18
+ # HTTPX default; kept explicit so ping reconnects cannot omit a bound.
19
+ MAX_RETRIES = 3
20
+ # Forces the selector timer retry path. Unset retry_after recurses in fetch_response;
21
+ # combined with ping! on a reused closed selector that never decrements retries,
22
+ # that overflows the stack and kills Falcon workers.
23
+ RETRY_AFTER_SECONDS = 0.25
24
+ # Bounds passed to the retries plugin and every +session.with+.
25
+ # HTTPX retries stay inside one {RequestService::Budget} slot and the shared
26
+ # wall-clock deadline; do not consume a slot per attempt.
27
+ # Do not reload +:fiber_concurrency+: a reused closed selector made +ping!+
28
+ # skip decrementing retries and recurse until +SystemStackError+.
29
+ #
30
+ # @return [Hash{Symbol => Integer, Float}]
31
+ RETRY_OPTIONS = { max_retries: MAX_RETRIES, retry_after: RETRY_AFTER_SECONDS }.freeze
32
+
19
33
  class << self
20
34
  # rubocop:disable ThreadSafety/ClassInstanceVariable
21
35
  # @return [HTTPX::Session]
22
36
  def base_session
23
- @base_sessions ||= {}
24
- @base_sessions[HTTPX::Session] ||= HTTPX
25
- .plugin(:follow_redirects)
26
- .plugin(:callbacks)
27
- .plugin(:brotli)
28
- .plugin(:fiber_concurrency)
29
- .plugin(:retries)
37
+ @base_session ||= HTTPX
38
+ .plugin(:follow_redirects)
39
+ .plugin(:callbacks)
40
+ .plugin(:brotli)
41
+ .plugin(:retries, **RETRY_OPTIONS)
30
42
  end
31
43
 
32
44
  # @return [HTTPX::Session]
33
45
  def base_ssrf_session
34
- @base_ssrf_sessions ||= {}
35
- @base_ssrf_sessions[HTTPX::Session] ||= base_session.plugin(:ssrf_filter)
46
+ @base_ssrf_session ||= base_session.plugin(:ssrf_filter)
36
47
  end
37
48
  # rubocop:enable ThreadSafety/ClassInstanceVariable
38
49
  end
39
50
 
40
- ##
41
- # @return [ResponseGuard]
42
- attr_reader :response_guard
43
-
44
51
  # Executes the request with runtime policy enforcement, returning the normalized response.
45
52
  #
46
53
  # @return [Response] normalized response
47
54
  def perform_execute
48
55
  deadline = request_deadline
49
- @response_guard = ResponseGuard.new(policy: ctx.policy)
50
56
  reset_redirect_tracking!
51
- raw_response = request_with_terminal_redirect_retry(response_guard, deadline:)
57
+ raw_response = request_with_terminal_redirect_retry(deadline:)
52
58
  build_response(raw_response)
53
59
  end
54
60
 
@@ -65,15 +71,15 @@ module Html2rss
65
71
  @current_url = request_url
66
72
  end
67
73
 
68
- def request_with_terminal_redirect_retry(response_guard, deadline:)
69
- raw_response = execute_http_request(response_guard, deadline:)
74
+ def request_with_terminal_redirect_retry(deadline:)
75
+ raw_response = execute_http_request(deadline:)
70
76
  return raw_response unless redirect_limit_reached?(raw_response)
71
77
 
72
78
  unless terminal_redirect_retryable?(raw_response)
73
79
  raise RedirectLimitReached, "Too many redirects (status #{raw_response.status})"
74
80
  end
75
81
 
76
- retry_from_terminal_redirect!(raw_response, response_guard, deadline:)
82
+ retry_from_terminal_redirect!(raw_response, deadline:)
77
83
  end
78
84
 
79
85
  def redirect_limit_reached?(response)
@@ -82,7 +88,7 @@ module Html2rss
82
88
  return false unless (300..399).cover?(response.status)
83
89
 
84
90
  location = response.headers['location']
85
- !location.nil? && !location.strip.empty?
91
+ !location.to_s.strip.empty?
86
92
  end
87
93
 
88
94
  def terminal_redirect_url(raw_response)
@@ -102,12 +108,12 @@ module Html2rss
102
108
  target && target.to_s != request_url.to_s
103
109
  end
104
110
 
105
- def retry_from_terminal_redirect!(raw_response, response_guard, deadline:)
111
+ def retry_from_terminal_redirect!(raw_response, deadline:)
106
112
  terminal_url = terminal_redirect_url(raw_response)
107
113
  @terminal_redirect_retried = true
108
114
  Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
109
115
  begin_terminal_url_request!(terminal_url)
110
- new_response = execute_http_request(response_guard, deadline:, consume_budget: false)
116
+ new_response = execute_http_request(deadline:, consume_budget: false)
111
117
  if redirect_limit_reached?(new_response)
112
118
  raise RedirectLimitReached, "Too many redirects (status #{new_response.status})"
113
119
  end
@@ -126,17 +132,21 @@ module Html2rss
126
132
  @request_url_override || ctx.url
127
133
  end
128
134
 
129
- def execute_http_request(response_guard, deadline:, consume_budget: true)
135
+ def execute_http_request(deadline:, consume_budget: true)
130
136
  preflight!(consume_budget:)
131
- session = build_session(response_guard, deadline:)
132
- response = session.get(request_url.to_s, headers: ctx.headers)
137
+ session = session_client(deadline)
138
+ response = http_get(session)
133
139
  raise response.error if response.is_a?(HTTPX::ErrorResponse)
134
140
 
135
141
  response
136
142
  end
137
143
 
138
- def build_session(_response_guard, deadline:)
139
- session_client(deadline)
144
+ # GET only. Overflow here is HTTPX retry recursion; the same error from
145
+ # redirect handling or body decode must stay loud.
146
+ def http_get(session)
147
+ session.get(request_url.to_s, headers: ctx.headers)
148
+ rescue SystemStackError
149
+ raise HTTPX::ConnectionError, 'HTTPX retry recursion exhausted'
140
150
  end
141
151
 
142
152
  def session_client(deadline)
@@ -152,7 +162,8 @@ module Html2rss
152
162
  follow_insecure_redirects: false,
153
163
  max_response_body_size: ctx.policy.max_response_bytes,
154
164
  resolver_class: :system,
155
- redirect_on: redirect_callback
165
+ redirect_on: redirect_callback,
166
+ **RETRY_OPTIONS
156
167
  }
157
168
  end
158
169
 
@@ -13,7 +13,7 @@ module Html2rss
13
13
  # {Policy} so SSRF/origin rules stay single-homed on the public façade.
14
14
  class NetworkGuard
15
15
  # Hostnames treated as local/private surfaces.
16
- LOCAL_HOSTS = %w[localhost localhost.localdomain metadata.google.internal].to_set.freeze
16
+ LOCAL_HOSTS = Set['localhost', 'localhost.localdomain', 'metadata.google.internal'].freeze
17
17
  # IP ranges blocked when private networks are disabled.
18
18
  BLOCKED_IP_RANGES = [
19
19
  IPAddr.new('0.0.0.0/8'),
@@ -32,7 +32,9 @@ module Html2rss
32
32
 
33
33
  ##
34
34
  # @param allow_private_networks [Boolean] whether private network targets are allowed
35
- # @param resolver [#each_address, #getaddrinfo] DNS resolver used for hostname classification
35
+ # @param resolver [Socket, Resolv] DNS resolver used for hostname classification.
36
+ # +Socket+ (the default) uses +getaddrinfo+; any other resolver is Resolv-shaped
37
+ # and must implement +each_address+.
36
38
  def initialize(allow_private_networks:, resolver: Socket)
37
39
  @allow_private_networks = allow_private_networks ? true : false
38
40
  @resolver = resolver
@@ -89,10 +91,10 @@ module Html2rss
89
91
  literal = parse_ip(host)
90
92
  return [literal] if literal
91
93
 
92
- if resolver.respond_to?(:each_address)
93
- addresses_from_each_address(host)
94
- else
94
+ if resolver == Socket
95
95
  addresses_from_getaddrinfo(host)
96
+ else
97
+ addresses_from_each_address(host)
96
98
  end
97
99
  rescue Resolv::ResolvError, SocketError, SystemCallError
98
100
  []
@@ -36,8 +36,7 @@ module Html2rss
36
36
  captured_responses: EMPTY_CAPTURED_RESPONSES)
37
37
  @body = body
38
38
 
39
- headers = headers.dup
40
- headers.transform_keys!(&:to_s)
39
+ headers = (headers || {}).transform_keys(&:to_s)
41
40
  HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
42
41
 
43
42
  @headers = headers
@@ -22,7 +22,7 @@ module Html2rss
22
22
  def execute
23
23
  check_timeout!
24
24
  response = perform_execute
25
- postflight!(response, response_guard:)
25
+ postflight!(response)
26
26
  response
27
27
  rescue StandardError => error
28
28
  handle_error(error)
@@ -67,13 +67,11 @@ module Html2rss
67
67
  # Enforces response-size and blocked-surface checks after transport work.
68
68
  #
69
69
  # @param response [Response, nil] adapter response
70
- # @param response_guard [ResponseGuard, nil] existing guard to reuse (e.g. streaming)
71
70
  # @return [void]
72
- def postflight!(response, response_guard: nil)
71
+ def postflight!(response)
73
72
  return if skip_postflight? || response.nil?
74
73
 
75
- guard = response_guard || ResponseGuard.new(policy: ctx.policy)
76
- guard.inspect_body!(response.body)
74
+ ResponseGuard.new(policy: ctx.policy).inspect_body!(response.body)
77
75
  end
78
76
 
79
77
  ##
@@ -91,9 +89,6 @@ module Html2rss
91
89
  raise
92
90
  end
93
91
 
94
- # @return [ResponseGuard, nil]
95
- def response_guard = nil
96
-
97
92
  # @param error [StandardError]
98
93
  # @return [void]
99
94
  # @raise [StandardError]
@@ -70,7 +70,9 @@ module Html2rss
70
70
  end
71
71
 
72
72
  def word_count(text)
73
- text.to_s.scan(/\p{Alnum}+/).size
73
+ count = 0
74
+ text.to_s.scan(/\p{Alnum}+/) { count += 1 }
75
+ count
74
76
  end
75
77
  end
76
78
  end
@@ -10,14 +10,14 @@ module Html2rss
10
10
  # Minimum composite score retained by {#rank_top} (precision floor).
11
11
  SCORE_FLOOR = 0.0
12
12
 
13
- FEATURE_IDS = %i[
14
- title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
15
- publish_marker descriptive_context article_container content_tokens
16
- non_content_utility_path utility_prefix_title_short shallow weak_container
17
- recommended_title_non_content high_confidence_junk_path junk_tokens
18
- heading_anchor heading_text_match meaningful_text content_like_destination
19
- cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
20
- ].to_set.freeze
13
+ FEATURE_IDS = Set[
14
+ :title_word_count_ge3, :title_word_count_ge7, :path_length_gt6, :content_path,
15
+ :publish_marker, :descriptive_context, :article_container, :content_tokens,
16
+ :non_content_utility_path, :utility_prefix_title_short, :shallow, :weak_container,
17
+ :recommended_title_non_content, :high_confidence_junk_path, :junk_tokens,
18
+ :heading_anchor, :heading_text_match, :meaningful_text, :content_like_destination,
19
+ :cluster_size, :cluster_avg_words, :cluster_heading, :cluster_time, :cluster_date
20
+ ].freeze
21
21
  private_constant :FEATURE_IDS
22
22
 
23
23
  # Quality feature triples: [feature_id, predicate, weight].
@@ -2,8 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  module Scoring
5
- ##
6
- # Typed container signals produced by {ContainerAssessor} for {Engine} rules.
7
5
  Observation = Data.define(
8
6
  :title_word_count,
9
7
  :path_length,
@@ -21,7 +19,11 @@ module Html2rss
21
19
  :high_confidence_junk_path,
22
20
  :high_confidence_utility_destination,
23
21
  :selected_anchor_present
24
- ) do
22
+ )
23
+
24
+ ##
25
+ # Typed container signals produced by {ContainerAssessor} for {Engine} rules.
26
+ class Observation
25
27
  ##
26
28
  # @return [Boolean] true when fewer than two positive content signals are present
27
29
  def weak_signals?
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Html2rss
4
4
  module Scoring
5
+ RankedSegment = Data.define(:segment, :score)
6
+
5
7
  ##
6
8
  # A segment paired with its composite score.
7
- RankedSegment = Data.define(:segment, :score) do
9
+ class RankedSegment
8
10
  ##
9
11
  # @return [SST::Node]
10
12
  def root_node = segment.root_node
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Html2rss
4
+ ScrapeTarget = Data.define(:entry_url, :effective_url)
5
+
4
6
  ##
5
7
  # Immutable entry vs effective scrape URLs for one pipeline run.
6
8
  #
7
9
  # Replaces mutating +Config#scrape_url=+ after {FeedResolution} rewrites the fetch URL.
8
- ScrapeTarget = Data.define(:entry_url, :effective_url) do
10
+ class ScrapeTarget
9
11
  ##
10
12
  # @param config [Html2rss::Config]
11
13
  # @return [ScrapeTarget]