html2rss 0.25.0 → 0.26.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  6. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  7. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  8. data/lib/html2rss/auto_source/scraper.rb +34 -15
  9. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  10. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  11. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  12. data/lib/html2rss/auto_source.rb +7 -2
  13. data/lib/html2rss/capture.rb +315 -0
  14. data/lib/html2rss/cli.rb +64 -5
  15. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  16. data/lib/html2rss/config/schema.rb +67 -26
  17. data/lib/html2rss/config/selectors_validator.rb +60 -21
  18. data/lib/html2rss/config/validator.rb +18 -27
  19. data/lib/html2rss/error.rb +15 -3
  20. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  21. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  22. data/lib/html2rss/feed_pipeline.rb +10 -3
  23. data/lib/html2rss/hash_util.rb +17 -0
  24. data/lib/html2rss/html/sst_article_extractor.rb +36 -7
  25. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  26. data/lib/html2rss/link_destination/path_classifier.rb +3 -0
  27. data/lib/html2rss/mcp/server.rb +577 -0
  28. data/lib/html2rss/mcp.rb +21 -0
  29. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  30. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  31. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  32. data/lib/html2rss/request_service/budget.rb +7 -35
  33. data/lib/html2rss/request_service/context.rb +0 -6
  34. data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
  35. data/lib/html2rss/request_service/policy.rb +1 -1
  36. data/lib/html2rss/request_service/response.rb +15 -1
  37. data/lib/html2rss/request_service/strategy.rb +1 -2
  38. data/lib/html2rss/request_service.rb +4 -9
  39. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  40. data/lib/html2rss/scoring/engine.rb +49 -5
  41. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  42. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  43. data/lib/html2rss/scoring/score.rb +1 -20
  44. data/lib/html2rss/scoring.rb +0 -25
  45. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  46. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  47. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  48. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  49. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  50. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  51. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  52. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  53. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  54. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  55. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  56. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  57. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  58. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  59. data/lib/html2rss/url.rb +11 -7
  60. data/lib/html2rss/version.rb +1 -1
  61. data/lib/html2rss.rb +32 -1
  62. data/schema/html2rss-config.schema.json +408 -85
  63. metadata +54 -12
  64. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  65. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  66. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  67. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  68. data/lib/html2rss/scoring/anchor_score.rb +0 -34
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html2rss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gil Desmarais
@@ -113,6 +113,20 @@ dependencies:
113
113
  - - ">="
114
114
  - !ruby/object:Gem::Version
115
115
  version: '0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: mcp
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '1.0'
123
+ type: :runtime
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '1.0'
116
130
  - !ruby/object:Gem::Dependency
117
131
  name: mime-types
118
132
  requirement: !ruby/object:Gem::Requirement
@@ -148,19 +162,33 @@ dependencies:
148
162
  - !ruby/object:Gem::Version
149
163
  version: '2.0'
150
164
  - !ruby/object:Gem::Dependency
151
- name: puppeteer-ruby
165
+ name: rack
152
166
  requirement: !ruby/object:Gem::Requirement
153
167
  requirements:
154
- - - ">="
168
+ - - "~>"
155
169
  - !ruby/object:Gem::Version
156
- version: '0'
170
+ version: '3.0'
157
171
  type: :runtime
158
172
  prerelease: false
159
173
  version_requirements: !ruby/object:Gem::Requirement
160
174
  requirements:
161
- - - ">="
175
+ - - "~>"
162
176
  - !ruby/object:Gem::Version
163
- version: '0'
177
+ version: '3.0'
178
+ - !ruby/object:Gem::Dependency
179
+ name: rackup
180
+ requirement: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - "~>"
183
+ - !ruby/object:Gem::Version
184
+ version: '2.0'
185
+ type: :runtime
186
+ prerelease: false
187
+ version_requirements: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - "~>"
190
+ - !ruby/object:Gem::Version
191
+ version: '2.0'
164
192
  - !ruby/object:Gem::Dependency
165
193
  name: regexp_parser
166
194
  requirement: !ruby/object:Gem::Requirement
@@ -245,6 +273,20 @@ dependencies:
245
273
  - - ">="
246
274
  - !ruby/object:Gem::Version
247
275
  version: '0'
276
+ - !ruby/object:Gem::Dependency
277
+ name: webrick
278
+ requirement: !ruby/object:Gem::Requirement
279
+ requirements:
280
+ - - "~>"
281
+ - !ruby/object:Gem::Version
282
+ version: '1.9'
283
+ type: :runtime
284
+ prerelease: false
285
+ version_requirements: !ruby/object:Gem::Requirement
286
+ requirements:
287
+ - - "~>"
288
+ - !ruby/object:Gem::Version
289
+ version: '1.9'
248
290
  - !ruby/object:Gem::Dependency
249
291
  name: zeitwerk
250
292
  requirement: !ruby/object:Gem::Requirement
@@ -300,12 +342,14 @@ files:
300
342
  - lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb
301
343
  - lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb
302
344
  - lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb
345
+ - lib/html2rss/auto_source/scraper/xhr_articles.rb
303
346
  - lib/html2rss/auto_source/segment.rb
304
347
  - lib/html2rss/auto_source/segmenter.rb
305
348
  - lib/html2rss/auto_source/segmenter/cluster.rb
306
349
  - lib/html2rss/auto_source/segmenter/list.rb
307
350
  - lib/html2rss/auto_source/segmenter/primary_link.rb
308
351
  - lib/html2rss/auto_source/segmenter/semantic.rb
352
+ - lib/html2rss/capture.rb
309
353
  - lib/html2rss/channel.rb
310
354
  - lib/html2rss/cli.rb
311
355
  - lib/html2rss/config.rb
@@ -360,20 +404,18 @@ files:
360
404
  - lib/html2rss/link_destination/noise_policy.rb
361
405
  - lib/html2rss/link_destination/path_classifier.rb
362
406
  - lib/html2rss/link_destination/text_classifier.rb
407
+ - lib/html2rss/mcp.rb
408
+ - lib/html2rss/mcp/server.rb
363
409
  - lib/html2rss/request_service.rb
364
410
  - lib/html2rss/request_service/blocked_surface.rb
365
411
  - lib/html2rss/request_service/botasaurus_contract.rb
366
412
  - lib/html2rss/request_service/botasaurus_strategy.rb
367
- - lib/html2rss/request_service/browserless_strategy.rb
368
413
  - lib/html2rss/request_service/budget.rb
369
414
  - lib/html2rss/request_service/context.rb
370
415
  - lib/html2rss/request_service/faraday_strategy.rb
371
416
  - lib/html2rss/request_service/local_file_strategy.rb
372
417
  - lib/html2rss/request_service/network_guard.rb
373
418
  - lib/html2rss/request_service/policy.rb
374
- - lib/html2rss/request_service/puppet_commander.rb
375
- - lib/html2rss/request_service/puppet_commander/navigation_guards.rb
376
- - lib/html2rss/request_service/puppet_commander/preload_runner.rb
377
419
  - lib/html2rss/request_service/response.rb
378
420
  - lib/html2rss/request_service/response_guard.rb
379
421
  - lib/html2rss/request_service/strategy.rb
@@ -386,7 +428,6 @@ files:
386
428
  - lib/html2rss/request_session/pager/rel_next.rb
387
429
  - lib/html2rss/request_session/pager/url_template.rb
388
430
  - lib/html2rss/scoring.rb
389
- - lib/html2rss/scoring/anchor_score.rb
390
431
  - lib/html2rss/scoring/cluster_scorer.rb
391
432
  - lib/html2rss/scoring/container_assessor.rb
392
433
  - lib/html2rss/scoring/engine.rb
@@ -415,6 +456,7 @@ files:
415
456
  - lib/html2rss/selectors/post_processors/sanitize_html.rb
416
457
  - lib/html2rss/selectors/post_processors/substring.rb
417
458
  - lib/html2rss/selectors/post_processors/template.rb
459
+ - lib/html2rss/selectors/schema_doc.rb
418
460
  - lib/html2rss/sst.rb
419
461
  - lib/html2rss/sst/attrs.rb
420
462
  - lib/html2rss/sst/document.rb
@@ -433,7 +475,7 @@ licenses:
433
475
  - MIT
434
476
  metadata:
435
477
  allowed_push_host: https://rubygems.org
436
- changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.25.0
478
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.26.0
437
479
  rubygems_mfa_required: 'true'
438
480
  rdoc_options: []
439
481
  require_paths:
@@ -1,132 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'puppeteer'
4
-
5
- module Html2rss
6
- class RequestService
7
- ##
8
- # Browserless.io strategy to request websites.
9
- #
10
- # Provide the WebSocket URL and your API token via environment variables:
11
- # - BROWSERLESS_IO_WEBSOCKET_URL
12
- # - BROWSERLESS_IO_API_TOKEN
13
- #
14
- # To use this strategy, you need to have a Browserless.io account or run a
15
- # local Browserless.io instance.
16
- #
17
- # @see https://www.browserless.io/
18
- #
19
- # To run a local Browserless.io instance, you can use the following Docker command:
20
- #
21
- # ```sh
22
- # docker run \
23
- # --rm \
24
- # -p 3000:3000 \
25
- # -e "CONCURRENT=10" \
26
- # -e "TOKEN=6R0W53R135510" \
27
- # ghcr.io/browserless/chromium
28
- # ```
29
- #
30
- # When running locally, you can skip setting the environment variables, as above commands
31
- # are aligned with the default values.
32
- # @see https://github.com/browserless/browserless/pkgs/container/chromium
33
- class BrowserlessStrategy < Strategy
34
- ##
35
- # @return [String] the Browserless websocket endpoint with token query param
36
- # @raise [ArgumentError] if a custom endpoint is configured without an API token
37
- def browser_ws_endpoint
38
- @browser_ws_endpoint ||= begin
39
- ws_url = ENV.fetch('BROWSERLESS_IO_WEBSOCKET_URL', 'ws://127.0.0.1:3000')
40
- api_token = browserless_api_token(ws_url)
41
-
42
- "#{ws_url}?token=#{api_token}"
43
- end
44
- end
45
-
46
- private
47
-
48
- def fetch
49
- connect_with_timeout_support do |browser|
50
- PuppetCommander.new(ctx, browser).call
51
- ensure
52
- browser.disconnect
53
- end
54
- end
55
-
56
- def protocol_timeout_ms
57
- ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
58
- end
59
-
60
- def connect_with_timeout_support(&)
61
- connect_browserless(protocol_timeout: protocol_timeout_ms, &)
62
- rescue ArgumentError => error
63
- raise unless unsupported_protocol_timeout?(error)
64
-
65
- connect_browserless(&)
66
- end
67
-
68
- def unsupported_protocol_timeout?(error)
69
- error.message.include?('unknown keyword: :protocol_timeout')
70
- end
71
-
72
- def connect_browserless(protocol_timeout: nil, &)
73
- connected = false
74
-
75
- Puppeteer.connect(**browserless_connect_options(protocol_timeout)) do |browser|
76
- connected = true
77
- yield browser
78
- end
79
- rescue ArgumentError => error
80
- handle_connection_error(error, connected:, protocol_timeout:)
81
- rescue StandardError => error
82
- handle_connection_error(error, connected:)
83
- end
84
-
85
- def browserless_connect_options(protocol_timeout)
86
- { browser_ws_endpoint:, protocol_timeout: }.compact
87
- end
88
-
89
- def handle_connection_error(error, connected:, protocol_timeout: nil)
90
- raise if connected || compatibility_timeout_error?(error, protocol_timeout:)
91
-
92
- raise BrowserlessConnectionFailed, browserless_connection_message(error), cause: error
93
- end
94
-
95
- def compatibility_timeout_error?(error, protocol_timeout:)
96
- protocol_timeout && unsupported_protocol_timeout?(error)
97
- end
98
-
99
- def browserless_connection_message(error)
100
- base = "Browserless connection failed (#{error.class}: #{error.message})."
101
- endpoint_hint = "Check BROWSERLESS_IO_WEBSOCKET_URL (currently #{browserless_websocket_url})."
102
- token_hint = 'Check BROWSERLESS_IO_API_TOKEN and ensure it matches your Browserless TOKEN.'
103
- local_hint = 'For local Browserless, confirm the service is running and reachable.'
104
-
105
- if likely_authentication_error?(error)
106
- "#{base} #{token_hint} #{endpoint_hint}"
107
- else
108
- "#{base} #{endpoint_hint} #{token_hint} #{local_hint}"
109
- end
110
- end
111
-
112
- def likely_authentication_error?(error)
113
- message = error.message.downcase
114
- message.include?('unauthorized') || message.include?('forbidden') || message.include?('401')
115
- end
116
-
117
- def browserless_websocket_url
118
- ENV.fetch('BROWSERLESS_IO_WEBSOCKET_URL', 'ws://127.0.0.1:3000')
119
- end
120
-
121
- def browserless_api_token(ws_url)
122
- ENV.fetch('BROWSERLESS_IO_API_TOKEN') do
123
- return '6R0W53R135510' if ws_url == 'ws://127.0.0.1:3000'
124
-
125
- raise BrowserlessConfigurationError,
126
- 'BROWSERLESS_IO_API_TOKEN is required for custom Browserless endpoints. ' \
127
- 'Set BROWSERLESS_IO_API_TOKEN or use ws://127.0.0.1:3000 for local defaults.'
128
- end
129
- end
130
- end
131
- end
132
- end
@@ -1,148 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestService
5
- class PuppetCommander
6
- ##
7
- # Owns Puppeteer request interception, deferred navigation errors, main-frame
8
- # filtering, and redirect-chain validation via {Policy}.
9
- #
10
- # Does not re-own Policy rules — only calls +validate_*!+ on +ctx.policy+.
11
- class NavigationGuards
12
- class << self
13
- ##
14
- # Resolves the final response URL for Response building and IP validation.
15
- #
16
- # @param navigation_response [Puppeteer::HTTPResponse, nil]
17
- # @param fallback_url [String, Html2rss::Url]
18
- # @return [Html2rss::Url]
19
- def response_url(navigation_response, fallback_url)
20
- raw_url = navigation_response&.url || fallback_url.to_s
21
- Html2rss::Url.from_absolute(raw_url)
22
- end
23
- end
24
-
25
- ##
26
- # @param ctx [Context] request context providing policy and origin
27
- # @param skip_request_resources [Set<String>] resource types to abort
28
- def initialize(ctx:, skip_request_resources:)
29
- @ctx = ctx
30
- @skip_request_resources = skip_request_resources
31
- @navigation_error = nil
32
- @latest_navigation_response = nil
33
- @main_frame = nil
34
- end
35
-
36
- ##
37
- # Captures the main frame and wires request/response interceptors.
38
- #
39
- # @param page [Puppeteer::Page] browser page
40
- # @return [void]
41
- def install!(page)
42
- @main_frame = page.main_frame if page.respond_to?(:main_frame)
43
- page.request_interception = true
44
- page.on('request') { |request| handle_request(request) }
45
- page.on('response') { |response| handle_response(response) }
46
- end
47
-
48
- ##
49
- # Clears deferred error and latest navigation response before goto.
50
- #
51
- # @return [void]
52
- def begin_navigation!
53
- @navigation_error = nil
54
- @latest_navigation_response = nil
55
- end
56
-
57
- ##
58
- # Re-raises a deferred navigation error when one was captured.
59
- #
60
- # @return [void]
61
- # @raise [Html2rss::Error] when a navigation request or response validation failed
62
- def raise_deferred_error!
63
- raise @navigation_error if @navigation_error
64
- end
65
-
66
- ##
67
- # @return [Puppeteer::HTTPResponse, nil] latest main-frame navigation response
68
- attr_reader :latest_navigation_response
69
-
70
- ##
71
- # Validates the remote IP of a navigation response via Policy.
72
- #
73
- # @param navigation_response [Puppeteer::HTTPResponse, nil]
74
- # @return [void]
75
- def validate_final!(navigation_response)
76
- final_url = self.class.response_url(navigation_response, ctx.url)
77
- ctx.policy.validate_remote_ip!(ip: remote_ip(navigation_response), url: final_url)
78
- end
79
-
80
- private
81
-
82
- attr_reader :ctx, :skip_request_resources, :main_frame
83
-
84
- def handle_request(request)
85
- validate_request!(request)
86
-
87
- skip_request_resources.member?(request.resource_type) ? request.abort : request.continue
88
- rescue Html2rss::Error => error
89
- store_navigation_error(error, navigation_request: request.navigation_request?)
90
- request.abort
91
- end
92
-
93
- def handle_response(response)
94
- @latest_navigation_response = response if main_frame_navigation_response?(response)
95
- validate_final!(response)
96
- rescue Html2rss::Error => error
97
- store_navigation_error(error, navigation_request: response.request.navigation_request?)
98
- end
99
-
100
- def validate_request!(request)
101
- validate_navigation_redirect_chain!(request)
102
- validate_navigation_target!(request)
103
- end
104
-
105
- def main_frame_navigation_response?(response)
106
- request = response.request
107
- return false unless request.navigation_request?
108
- return true unless request.respond_to?(:frame)
109
-
110
- frame = request.frame
111
- return true if frame.nil?
112
- return frame == main_frame unless main_frame.nil?
113
- return true unless frame.respond_to?(:parent_frame)
114
-
115
- frame.parent_frame.nil?
116
- end
117
-
118
- def remote_ip(navigation_response)
119
- navigation_response.remote_address&.ip
120
- end
121
-
122
- def request_chain(request)
123
- (request.redirect_chain + [request]).map { |entry| request_url(entry) }
124
- end
125
-
126
- def request_url(request)
127
- Html2rss::Url.from_absolute(request.url)
128
- end
129
-
130
- def validate_navigation_redirect_chain!(request)
131
- request_chain(request).each_cons(2) do |from_url, to_url|
132
- ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
133
- end
134
- end
135
-
136
- def validate_navigation_target!(request)
137
- ctx.policy.validate_request!(url: request_url(request), origin_url: ctx.origin_url, relation: ctx.relation)
138
- end
139
-
140
- def store_navigation_error(error, navigation_request:)
141
- return unless navigation_request
142
-
143
- @navigation_error = error if @navigation_error.nil?
144
- end
145
- end
146
- end
147
- end
148
- end
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestService
5
- class PuppetCommander
6
- ##
7
- # Runs Browserless preload choreography: wait, click, and scroll against
8
- # the interaction budget on {Context#budget}.
9
- class PreloadRunner
10
- ##
11
- # @param ctx [Context] request context providing preload config and budget
12
- def initialize(ctx:)
13
- @ctx = ctx
14
- end
15
-
16
- ##
17
- # Executes configured preload actions on the page, if any.
18
- #
19
- # @param page [Puppeteer::Page] browser page after initial navigation
20
- # @return [void]
21
- def call(page)
22
- preload_config = ctx.browserless_preload
23
- return unless preload_config
24
-
25
- wait_after(page, preload_config[:wait_after_ms])
26
- click_selectors(page, preload_config[:click_selectors]) if preload_config[:click_selectors]
27
- scroll_down(page, preload_config[:scroll_down]) if preload_config[:scroll_down]
28
- wait_after(page, preload_config[:wait_after_ms])
29
- end
30
-
31
- private
32
-
33
- attr_reader :ctx
34
-
35
- def wait_after(page, timeout_ms)
36
- return unless timeout_ms
37
-
38
- ctx.budget.consume_interaction!
39
- page.wait_for_timeout(timeout_ms)
40
- end
41
-
42
- def click_selectors(page, selectors)
43
- selectors.each { |selector_config| click_selector(page, selector_config) }
44
- end
45
-
46
- def scroll_down(page, config)
47
- iterations = config.fetch(:iterations, 1)
48
- wait_after_ms = config[:wait_after_ms]
49
- previous_height = nil
50
-
51
- iterations.times do
52
- updated_height = perform_scroll_iteration(page, wait_after_ms, previous_height)
53
- break unless updated_height
54
-
55
- previous_height = updated_height
56
- end
57
- end
58
-
59
- def click_selector(page, config)
60
- selector = config.fetch(:selector)
61
- max_clicks = config.fetch(:max_clicks, 1)
62
- wait_after_ms = config[:wait_after_ms]
63
-
64
- max_clicks.times do
65
- break unless (element = page.query_selector(selector))
66
-
67
- ctx.budget.consume_interaction!
68
- element.click
69
- wait_after(page, wait_after_ms)
70
- end
71
- end
72
-
73
- def perform_scroll_iteration(page, wait_after_ms, previous_height)
74
- ctx.budget.consume_interaction!
75
- page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)')
76
- wait_after(page, wait_after_ms)
77
-
78
- current_height = page.evaluate('() => document.body.scrollHeight')
79
- return if previous_height && current_height <= previous_height
80
-
81
- current_height
82
- end
83
- end
84
- end
85
- end
86
- end
@@ -1,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestService
5
- ##
6
- # Commands the Puppeteer Browser to the website and builds the Response.
7
- #
8
- # Public interface is {#call}; page setup, navigation, and body collection
9
- # are private orchestration steps.
10
- class PuppetCommander
11
- # Request header names that must not be forwarded to the browser session.
12
- BROWSER_UNSAFE_HEADERS = %w[
13
- host connection content-length transfer-encoding
14
- sec-fetch-dest sec-fetch-mode sec-fetch-site sec-fetch-user
15
- upgrade-insecure-requests
16
- ].to_set.freeze
17
-
18
- # @param ctx [Context]
19
- # @param browser [Puppeteer::Browser]
20
- # @param skip_request_resources [Set<String>] the resource types not to request
21
- # @param referer [String] the referer to use for the request
22
- def initialize(ctx,
23
- browser,
24
- skip_request_resources: %w[stylesheet image media font].to_set,
25
- referer: [ctx.url.scheme, ctx.url.host].join('://'))
26
- @ctx = ctx
27
- @browser = browser
28
- @referer = referer
29
- @navigation_guards = NavigationGuards.new(ctx:, skip_request_resources:)
30
- @preload_runner = PreloadRunner.new(ctx:)
31
- end
32
-
33
- ##
34
- # Visits the request URL and normalizes the page into a response object.
35
- #
36
- # @return [Response] rendered page response
37
- def call
38
- page = new_page
39
- navigation_response = navigate_to_destination(page, ctx.url)
40
- preload_runner.call(page)
41
- navigation_guards.raise_deferred_error!
42
- final_navigation_response = navigation_guards.latest_navigation_response || navigation_response
43
- navigation_guards.validate_final!(final_navigation_response)
44
- build_response(page, final_navigation_response)
45
- ensure
46
- page&.close
47
- end
48
-
49
- private
50
-
51
- attr_reader :ctx, :browser, :referer, :navigation_guards, :preload_runner
52
-
53
- def new_page
54
- page = browser.new_page
55
- configure_page(page)
56
- navigation_guards.install!(page)
57
- page
58
- end
59
-
60
- def configure_page(page)
61
- page.extra_http_headers = browser_headers
62
- page.default_navigation_timeout = navigation_timeout_ms
63
- page.default_timeout = navigation_timeout_ms
64
- end
65
-
66
- def navigate_to_destination(page, url)
67
- navigation_guards.begin_navigation!
68
- page.goto(url, wait_until: 'networkidle0', referer:, timeout: navigation_timeout_ms).tap do
69
- navigation_guards.raise_deferred_error!
70
- end
71
- rescue StandardError
72
- navigation_guards.raise_deferred_error!
73
-
74
- raise
75
- end
76
-
77
- def navigation_timeout_ms
78
- ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
79
- end
80
-
81
- def browser_headers
82
- ctx.headers.reject { |key, _| BROWSER_UNSAFE_HEADERS.include?(key.to_s.downcase) }
83
- end
84
-
85
- def build_response(page, navigation_response)
86
- Response.new(
87
- body: page.content,
88
- headers: navigation_response&.headers || {},
89
- url: NavigationGuards.response_url(navigation_response, ctx.url),
90
- status: navigation_response&.status
91
- )
92
- end
93
- end
94
- end
95
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- module Scoring
5
- ##
6
- # Primary-link ranking weights (sole home; FeatureId-aligned).
7
- module AnchorScore
8
- # Signal → integer weight map for primary-link ranking.
9
- WEIGHTS = {
10
- heading_anchor: 100,
11
- heading_text_match: 20,
12
- meaningful_text: 10,
13
- content_like_destination: 10
14
- }.freeze
15
-
16
- module_function
17
-
18
- ##
19
- # @param heading_anchor [Boolean]
20
- # @param heading_text_match [Boolean]
21
- # @param meaningful_text [Boolean]
22
- # @param content_like_destination [Boolean]
23
- # @return [Integer]
24
- def score(heading_anchor:, heading_text_match:, meaningful_text:, content_like_destination:)
25
- total = 0
26
- total += WEIGHTS[:heading_anchor] if heading_anchor
27
- total += WEIGHTS[:heading_text_match] if heading_text_match
28
- total += WEIGHTS[:meaningful_text] if meaningful_text
29
- total += WEIGHTS[:content_like_destination] if content_like_destination
30
- total
31
- end
32
- end
33
- end
34
- end