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.
- checksums.yaml +4 -4
- data/README.md +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +34 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +7 -2
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +64 -5
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +18 -27
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +10 -3
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/sst_article_extractor.rb +36 -7
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +3 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -1
- data/schema/html2rss-config.schema.json +408 -85
- metadata +54 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- 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.
|
|
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:
|
|
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.
|
|
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
|