html2rss 0.32.4 → 0.33.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/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +7 -3
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
data/lib/html2rss/recon.rb
CHANGED
|
@@ -9,8 +9,6 @@ module Html2rss
|
|
|
9
9
|
# Service that runs reconnaissance on a URL to discover redirect chains,
|
|
10
10
|
# native RSS/Atom feeds, surface categorization, and emit an actionable verdict.
|
|
11
11
|
module Recon # rubocop:disable Metrics/ModuleLength
|
|
12
|
-
module_function
|
|
13
|
-
|
|
14
12
|
Verdict = Data.define(:name)
|
|
15
13
|
|
|
16
14
|
##
|
|
@@ -106,12 +104,12 @@ module Html2rss
|
|
|
106
104
|
# @option options [Integer, nil] :max_redirects optional maximum redirects
|
|
107
105
|
# @option options [Integer, nil] :max_requests optional request budget
|
|
108
106
|
# @return [Html2rss::Recon::Result]
|
|
109
|
-
def call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
107
|
+
def self.call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
110
108
|
url_obj = Url.from_absolute(url)
|
|
111
109
|
|
|
112
110
|
begin
|
|
113
111
|
probe = PageRecon.probe(url_obj, strategy:, **)
|
|
114
|
-
rescue
|
|
112
|
+
rescue Html2rss::Error => error
|
|
115
113
|
return error_result(url_obj, error)
|
|
116
114
|
end
|
|
117
115
|
|
|
@@ -149,7 +147,7 @@ module Html2rss
|
|
|
149
147
|
# @option options [Integer, nil] :max_redirects optional maximum redirects
|
|
150
148
|
# @option options [Integer, nil] :max_requests optional request budget
|
|
151
149
|
# @return [Array<Html2rss::Recon::Result>]
|
|
152
|
-
def batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
|
|
150
|
+
def self.batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
|
|
153
151
|
url_list = urls.map { |u| u.to_s.strip }.reject(&:empty?)
|
|
154
152
|
return [] if url_list.empty?
|
|
155
153
|
|
|
@@ -166,7 +164,7 @@ module Html2rss
|
|
|
166
164
|
# @param session [Html2rss::RequestSession]
|
|
167
165
|
# @param response [Html2rss::RequestService::Response]
|
|
168
166
|
# @return [Array(Html2rss::Url, nil), String, nil)] feed URL and optional discovery error note
|
|
169
|
-
def find_native_feed(url_obj, session, response)
|
|
167
|
+
def self.find_native_feed(url_obj, session, response)
|
|
170
168
|
feed = Syndication::Discovery.best_feed_url(
|
|
171
169
|
page_url: url_obj,
|
|
172
170
|
request_session: session,
|
|
@@ -174,12 +172,12 @@ module Html2rss
|
|
|
174
172
|
html: response.body
|
|
175
173
|
)
|
|
176
174
|
[feed, nil]
|
|
177
|
-
rescue
|
|
175
|
+
rescue Html2rss::Error => error
|
|
178
176
|
[nil, "discovery_error=#{error.class}: #{error.message}"]
|
|
179
177
|
end
|
|
180
178
|
private_class_method :find_native_feed
|
|
181
179
|
|
|
182
|
-
def build_notes(recon, native_feed, response)
|
|
180
|
+
def self.build_notes(recon, native_feed, response)
|
|
183
181
|
notes = []
|
|
184
182
|
notes << "native_rss=#{native_feed}" if native_feed
|
|
185
183
|
if recon.scheme_downgrade
|
|
@@ -192,7 +190,7 @@ module Html2rss
|
|
|
192
190
|
end
|
|
193
191
|
private_class_method :build_notes
|
|
194
192
|
|
|
195
|
-
def determine_verdict(recon, native_feed)
|
|
193
|
+
def self.determine_verdict(recon, native_feed)
|
|
196
194
|
return Verdict[:drop] if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
|
|
197
195
|
return Verdict[:defer] if native_feed
|
|
198
196
|
|
|
@@ -203,7 +201,7 @@ module Html2rss
|
|
|
203
201
|
end
|
|
204
202
|
private_class_method :determine_verdict
|
|
205
203
|
|
|
206
|
-
def error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
|
|
204
|
+
def self.error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
|
|
207
205
|
Result.new(
|
|
208
206
|
requested_url: url_obj,
|
|
209
207
|
final_url: url_obj,
|
|
@@ -226,14 +224,14 @@ module Html2rss
|
|
|
226
224
|
# @param cache_dir [String]
|
|
227
225
|
# @param cache_mutex [Mutex, nil]
|
|
228
226
|
# @return [void]
|
|
229
|
-
def cache_html_body(body, url_obj, cache_dir, cache_mutex)
|
|
227
|
+
def self.cache_html_body(body, url_obj, cache_dir, cache_mutex)
|
|
230
228
|
host = url_obj.host.to_s.delete_prefix('www.')
|
|
231
229
|
host = 'snapshot' if host.empty?
|
|
232
230
|
digest = Digest::SHA256.hexdigest(url_obj.to_s)[0, 12]
|
|
233
231
|
file_path = File.join(cache_dir, "#{host}-#{digest}.html")
|
|
234
232
|
writer = -> { File.write(file_path, body) }
|
|
235
233
|
cache_mutex ? cache_mutex.synchronize(&writer) : writer.call
|
|
236
|
-
rescue
|
|
234
|
+
rescue SystemCallError, IOError => error
|
|
237
235
|
Log.debug("Recon cache failed: #{error.message}")
|
|
238
236
|
end
|
|
239
237
|
private_class_method :cache_html_body
|
|
@@ -79,10 +79,10 @@ module Html2rss
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
def self.interstitial_signature_match?(body, signature)
|
|
82
|
-
min_matches = signature
|
|
82
|
+
min_matches = signature[:min_matches]
|
|
83
83
|
matches = 0
|
|
84
84
|
|
|
85
|
-
signature
|
|
85
|
+
signature[:patterns].each do |pattern|
|
|
86
86
|
matches += 1 if pattern.match?(body)
|
|
87
87
|
return true if matches >= min_matches
|
|
88
88
|
end
|
|
@@ -91,11 +91,7 @@ module Html2rss
|
|
|
91
91
|
end
|
|
92
92
|
private_class_method :interstitial_signature_match?
|
|
93
93
|
|
|
94
|
-
def self.normalize_body(body)
|
|
95
|
-
body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
|
|
96
|
-
rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
97
|
-
body.to_s.force_encoding(Encoding::UTF_8).scrub
|
|
98
|
-
end
|
|
94
|
+
def self.normalize_body(body) = body.to_s.scrub
|
|
99
95
|
private_class_method :normalize_body
|
|
100
96
|
end
|
|
101
97
|
end
|
|
@@ -65,7 +65,7 @@ module Html2rss
|
|
|
65
65
|
return ctx.url if final_url.nil?
|
|
66
66
|
|
|
67
67
|
Html2rss::Url.from_absolute(final_url)
|
|
68
|
-
rescue
|
|
68
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
69
69
|
ctx.url
|
|
70
70
|
end
|
|
71
71
|
|
|
@@ -117,7 +117,7 @@ module Html2rss
|
|
|
117
117
|
raise BotasaurusConfigurationError, 'BOTASAURUS_SCRAPER_URL is required for strategy=botasaurus.'
|
|
118
118
|
end
|
|
119
119
|
Html2rss::Url.for_channel(configured)
|
|
120
|
-
rescue
|
|
120
|
+
rescue Html2rss::Url::InvalidUrlError => error
|
|
121
121
|
raise BotasaurusConfigurationError, "BOTASAURUS_SCRAPER_URL is invalid: #{error.message}"
|
|
122
122
|
end
|
|
123
123
|
end
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# @param max_requests [Integer] maximum HTTP request slots
|
|
12
12
|
# @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
|
|
13
13
|
def initialize(max_requests:, total_timeout_seconds: nil)
|
|
14
|
-
|
|
14
|
+
Option.integer(:max_requests, max_requests, min: 1)
|
|
15
15
|
|
|
16
16
|
@remaining_requests = max_requests
|
|
17
17
|
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -25,33 +25,20 @@ module Html2rss
|
|
|
25
25
|
# @return [Integer] remaining request slots after consumption
|
|
26
26
|
# @raise [RequestBudgetExceeded] if no request slots remain
|
|
27
27
|
def consume!
|
|
28
|
-
consume_request!
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
##
|
|
32
|
-
# Consumes one HTTP request slot.
|
|
33
|
-
#
|
|
34
|
-
# @return [Integer] remaining request slots after consumption
|
|
35
|
-
# @raise [RequestBudgetExceeded] if no request slots remain
|
|
36
|
-
def consume_request!
|
|
37
28
|
@mutex.synchronize do
|
|
38
29
|
raise RequestBudgetExceeded, 'Request budget exhausted' if @remaining_requests.zero?
|
|
39
30
|
|
|
40
31
|
@remaining_requests -= 1
|
|
41
32
|
end
|
|
42
33
|
end
|
|
43
|
-
|
|
44
|
-
##
|
|
45
|
-
# @return [Integer] HTTP request slots still available
|
|
46
|
-
def remaining
|
|
47
|
-
remaining_requests
|
|
48
|
-
end
|
|
34
|
+
alias consume_request! consume!
|
|
49
35
|
|
|
50
36
|
##
|
|
51
37
|
# @return [Integer] HTTP request slots still available
|
|
52
38
|
def remaining_requests
|
|
53
39
|
@mutex.synchronize { @remaining_requests }
|
|
54
40
|
end
|
|
41
|
+
alias remaining remaining_requests
|
|
55
42
|
|
|
56
43
|
##
|
|
57
44
|
# @return [Float, nil] the remaining timeout in seconds, or nil if not tracked
|
|
@@ -91,14 +78,6 @@ module Html2rss
|
|
|
91
78
|
def effective_timeout_ms(fallback:)
|
|
92
79
|
(effective_timeout_seconds(fallback:) * 1000).to_i
|
|
93
80
|
end
|
|
94
|
-
|
|
95
|
-
private
|
|
96
|
-
|
|
97
|
-
def validate_slot_limits!(max_requests:)
|
|
98
|
-
return if max_requests.is_a?(Integer) && max_requests.positive?
|
|
99
|
-
|
|
100
|
-
raise ArgumentError, 'max_requests must be positive'
|
|
101
|
-
end
|
|
102
81
|
end
|
|
103
82
|
end
|
|
104
83
|
end
|
|
@@ -7,56 +7,58 @@ require 'zlib'
|
|
|
7
7
|
module Html2rss
|
|
8
8
|
class RequestService
|
|
9
9
|
# Decodes gzip/brotli bodies when Content-Encoding is missing or the type is octet-stream.
|
|
10
|
-
|
|
10
|
+
class CompressedBody
|
|
11
11
|
# Gzip stream magic (RFC 1952).
|
|
12
12
|
GZIP_MAGIC = "\x1F\x8B".b.freeze
|
|
13
13
|
# HTML prefix for unlabeled brotli trial decode. Same object as {Response::HTML_BODY_SNIFF}.
|
|
14
14
|
HTML_BODY_SNIFF = Response::HTML_BODY_SNIFF
|
|
15
15
|
|
|
16
|
-
module_function
|
|
17
|
-
|
|
18
16
|
# @param body [String, nil] raw response body
|
|
19
17
|
# @param headers [Hash] response headers (Content-Encoding / Content-Type)
|
|
20
18
|
# @return [String, nil] decoded body when compressed; otherwise the original body
|
|
21
|
-
def decode(body, headers: {})
|
|
22
|
-
return body if body.nil? || body.empty?
|
|
23
|
-
|
|
19
|
+
def self.decode(body, headers: {})
|
|
24
20
|
raw = body.to_s.b
|
|
25
|
-
|
|
21
|
+
new(raw, headers).decoded_body || body
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param raw [String] binary response body
|
|
25
|
+
# @param headers [Hash] response headers (Content-Encoding / Content-Type)
|
|
26
|
+
def initialize(raw, headers)
|
|
27
|
+
@raw = raw
|
|
28
|
+
@headers = headers
|
|
26
29
|
end
|
|
30
|
+
private_class_method :new
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
# @return [String, nil] inflated body, or nil when the bytes are not a recognized stream
|
|
33
|
+
def decoded_body
|
|
34
|
+
decode_labeled || decode_unlabeled
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def decode_labeled
|
|
40
|
+
encodings = content_encodings
|
|
30
41
|
return if encodings.empty?
|
|
31
42
|
|
|
32
|
-
encodings.reverse.reduce(raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
|
|
43
|
+
encodings.reverse.reduce(@raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
|
|
33
44
|
end
|
|
34
|
-
module_function :decode_labeled
|
|
35
|
-
private_class_method :decode_labeled
|
|
36
45
|
|
|
37
|
-
def decode_unlabeled
|
|
38
|
-
return uncompress_gzip(raw) if raw.start_with?(GZIP_MAGIC)
|
|
39
|
-
return unless octet_stream?
|
|
46
|
+
def decode_unlabeled
|
|
47
|
+
return uncompress_gzip(@raw) if @raw.start_with?(GZIP_MAGIC)
|
|
48
|
+
return unless octet_stream?
|
|
40
49
|
|
|
41
|
-
try_brotli(raw)
|
|
50
|
+
try_brotli(@raw)
|
|
42
51
|
end
|
|
43
|
-
module_function :decode_unlabeled
|
|
44
|
-
private_class_method :decode_unlabeled
|
|
45
52
|
|
|
46
53
|
def inflate(raw, encoding)
|
|
47
54
|
case encoding
|
|
48
|
-
when 'gzip'
|
|
49
|
-
return raw unless raw.start_with?(GZIP_MAGIC)
|
|
50
|
-
|
|
51
|
-
uncompress_gzip(raw)
|
|
55
|
+
when 'gzip' then uncompress_gzip(raw)
|
|
52
56
|
when 'br' then try_brotli(raw, require_html: false)
|
|
53
57
|
when 'deflate' then inflate_deflate(raw)
|
|
54
58
|
end
|
|
55
59
|
rescue Zlib::Error, Brotli::Error, ArgumentError
|
|
56
60
|
nil
|
|
57
61
|
end
|
|
58
|
-
module_function :inflate
|
|
59
|
-
private_class_method :inflate
|
|
60
62
|
|
|
61
63
|
def inflate_deflate(raw)
|
|
62
64
|
inflater = nil
|
|
@@ -67,51 +69,37 @@ module Html2rss
|
|
|
67
69
|
ensure
|
|
68
70
|
inflater&.close
|
|
69
71
|
end
|
|
70
|
-
module_function :inflate_deflate
|
|
71
|
-
private_class_method :inflate_deflate
|
|
72
72
|
|
|
73
73
|
def uncompress_gzip(raw)
|
|
74
|
-
Zlib::GzipReader.wrap(StringIO.new(raw),
|
|
74
|
+
Zlib::GzipReader.wrap(StringIO.new(raw), &:read).b
|
|
75
75
|
rescue Zlib::Error
|
|
76
76
|
nil
|
|
77
77
|
end
|
|
78
|
-
module_function :uncompress_gzip
|
|
79
|
-
private_class_method :uncompress_gzip
|
|
80
78
|
|
|
81
79
|
def try_brotli(raw, require_html: true)
|
|
82
80
|
inflated = Brotli.inflate(raw)
|
|
83
81
|
return inflated unless require_html
|
|
84
|
-
return inflated if inflated.b.match?(HTML_BODY_SNIFF)
|
|
85
82
|
|
|
86
|
-
nil
|
|
83
|
+
inflated.b.match?(HTML_BODY_SNIFF) ? inflated : nil
|
|
87
84
|
rescue Brotli::Error, ArgumentError
|
|
88
85
|
nil
|
|
89
86
|
end
|
|
90
|
-
module_function :try_brotli
|
|
91
|
-
private_class_method :try_brotli
|
|
92
87
|
|
|
93
|
-
def content_encodings
|
|
94
|
-
header(
|
|
88
|
+
def content_encodings
|
|
89
|
+
header('content-encoding')
|
|
95
90
|
.to_s
|
|
96
91
|
.split(',')
|
|
97
|
-
.
|
|
92
|
+
.map { |val| val.strip.downcase }
|
|
93
|
+
.reject(&:empty?)
|
|
98
94
|
end
|
|
99
|
-
module_function :content_encodings
|
|
100
|
-
private_class_method :content_encodings
|
|
101
95
|
|
|
102
|
-
def octet_stream?
|
|
103
|
-
header(
|
|
96
|
+
def octet_stream?
|
|
97
|
+
header('content-type').to_s.downcase.include?('application/octet-stream')
|
|
104
98
|
end
|
|
105
|
-
module_function :octet_stream?
|
|
106
|
-
private_class_method :octet_stream?
|
|
107
99
|
|
|
108
|
-
def header(
|
|
109
|
-
headers.
|
|
110
|
-
headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
|
|
111
|
-
end
|
|
100
|
+
def header(name)
|
|
101
|
+
@headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
|
|
112
102
|
end
|
|
113
|
-
module_function :header
|
|
114
|
-
private_class_method :header
|
|
115
103
|
end
|
|
116
104
|
end
|
|
117
105
|
end
|
|
@@ -87,9 +87,7 @@ module Html2rss
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def normalize_request(request)
|
|
90
|
-
|
|
91
|
-
HashUtil.assert_symbol_keys!(normalized, context: 'request')
|
|
92
|
-
normalized
|
|
90
|
+
HashUtil.deep_symbolize_keys(request, context: 'request')
|
|
93
91
|
end
|
|
94
92
|
end
|
|
95
93
|
end
|
|
@@ -25,7 +25,6 @@ module Html2rss
|
|
|
25
25
|
def fetch
|
|
26
26
|
file_path = ctx.request[:local_file_path]
|
|
27
27
|
raise ArgumentError, 'Local file path is required for local_file strategy' unless file_path
|
|
28
|
-
raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
|
|
29
28
|
|
|
30
29
|
body = File.read(file_path)
|
|
31
30
|
Response.new(
|
|
@@ -68,7 +68,7 @@ module Html2rss
|
|
|
68
68
|
def validate_remote_ip!(ip:, url:)
|
|
69
69
|
return if allow_private_networks?
|
|
70
70
|
|
|
71
|
-
parsed_ip = ip
|
|
71
|
+
parsed_ip = parse_ip(ip.to_s)
|
|
72
72
|
raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
|
|
73
73
|
return unless blocked_ip?(parsed_ip)
|
|
74
74
|
|
|
@@ -122,7 +122,8 @@ module Html2rss
|
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
def blocked_ip?(address)
|
|
125
|
-
|
|
125
|
+
native = address.native
|
|
126
|
+
BLOCKED_IP_RANGES.any? { |range| range.include?(native) }
|
|
126
127
|
end
|
|
127
128
|
end
|
|
128
129
|
end
|
|
@@ -47,13 +47,13 @@ module Html2rss
|
|
|
47
47
|
allow_private_networks: DEFAULTS[:allow_private_networks],
|
|
48
48
|
allow_cross_origin_followups: DEFAULTS[:allow_cross_origin_followups],
|
|
49
49
|
resolver: Socket)
|
|
50
|
-
@connect_timeout_seconds =
|
|
51
|
-
@read_timeout_seconds =
|
|
52
|
-
@total_timeout_seconds =
|
|
53
|
-
@max_redirects =
|
|
54
|
-
@max_response_bytes =
|
|
55
|
-
@max_decompressed_bytes =
|
|
56
|
-
@max_requests = [
|
|
50
|
+
@connect_timeout_seconds = Option.integer(:connect_timeout_seconds, connect_timeout_seconds, min: 1)
|
|
51
|
+
@read_timeout_seconds = Option.integer(:read_timeout_seconds, read_timeout_seconds, min: 1)
|
|
52
|
+
@total_timeout_seconds = Option.integer(:total_timeout_seconds, total_timeout_seconds, min: 1)
|
|
53
|
+
@max_redirects = Option.integer(:max_redirects, max_redirects, min: 0)
|
|
54
|
+
@max_response_bytes = Option.integer(:max_response_bytes, max_response_bytes, min: 1)
|
|
55
|
+
@max_decompressed_bytes = Option.integer(:max_decompressed_bytes, max_decompressed_bytes, min: 1)
|
|
56
|
+
@max_requests = [Option.integer(:max_requests, max_requests, min: 1), MAX_REQUESTS_CEILING].min
|
|
57
57
|
@allow_private_networks = allow_private_networks ? true : false
|
|
58
58
|
@allow_cross_origin_followups = allow_cross_origin_followups ? true : false
|
|
59
59
|
@network_guard = NetworkGuard.new(allow_private_networks: @allow_private_networks, resolver:)
|
|
@@ -135,18 +135,6 @@ module Html2rss
|
|
|
135
135
|
|
|
136
136
|
attr_reader :network_guard
|
|
137
137
|
|
|
138
|
-
def validate_positive_integer!(name, value)
|
|
139
|
-
raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
|
|
140
|
-
|
|
141
|
-
value
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def validate_non_negative_integer!(name, value)
|
|
145
|
-
raise ArgumentError, "#{name} must be non-negative" unless value.is_a?(Integer) && !value.negative?
|
|
146
|
-
|
|
147
|
-
value
|
|
148
|
-
end
|
|
149
|
-
|
|
150
138
|
def enforce_same_origin!(url, origin_url, relation)
|
|
151
139
|
return if relation == :initial || allow_cross_origin_followups?
|
|
152
140
|
|
|
@@ -163,13 +151,7 @@ module Html2rss
|
|
|
163
151
|
end
|
|
164
152
|
|
|
165
153
|
def comparable_origin(url)
|
|
166
|
-
[url.host,
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def normalized_port(url)
|
|
170
|
-
return url.port if url.port
|
|
171
|
-
|
|
172
|
-
url.scheme == 'https' ? 443 : 80
|
|
154
|
+
[url.scheme, url.host, url.port]
|
|
173
155
|
end
|
|
174
156
|
end
|
|
175
157
|
|
|
@@ -31,15 +31,12 @@ module Html2rss
|
|
|
31
31
|
# @param status [Integer, nil] the HTTP status code when available
|
|
32
32
|
# @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
|
|
33
33
|
# @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
|
|
34
|
-
# rubocop:disable-next Metrics/
|
|
34
|
+
# rubocop:disable-next Metrics/ParameterLists -- transport + capture fields stay co-located
|
|
35
35
|
def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
|
|
36
36
|
captured_responses: EMPTY_CAPTURED_RESPONSES)
|
|
37
37
|
@body = body
|
|
38
38
|
|
|
39
|
-
headers = (headers || {}).transform_keys(&:to_s)
|
|
40
|
-
HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
|
|
41
|
-
|
|
42
|
-
@headers = headers
|
|
39
|
+
@headers = (headers || {}).transform_keys(&:to_s)
|
|
43
40
|
@status = status
|
|
44
41
|
@url = url
|
|
45
42
|
@transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
|
|
@@ -165,7 +162,7 @@ module Html2rss
|
|
|
165
162
|
end
|
|
166
163
|
|
|
167
164
|
def feed_looking_body?
|
|
168
|
-
snippet = body.to_s[0, 800].downcase
|
|
165
|
+
snippet = body.to_s.b[0, 800].downcase
|
|
169
166
|
FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) }
|
|
170
167
|
end
|
|
171
168
|
end
|
|
@@ -26,7 +26,7 @@ module Html2rss
|
|
|
26
26
|
# @yield [RequestService::Response] each page response
|
|
27
27
|
# @return [Enumerator] enumerator when no block is given
|
|
28
28
|
def each
|
|
29
|
-
return enum_for
|
|
29
|
+
return enum_for unless block_given?
|
|
30
30
|
|
|
31
31
|
yield initial_response
|
|
32
32
|
|
|
@@ -48,14 +48,14 @@ module Html2rss
|
|
|
48
48
|
|
|
49
49
|
# @return [Integer] configured maximum pages (always a positive Integer)
|
|
50
50
|
def max_pages
|
|
51
|
-
value = config
|
|
51
|
+
value = config[:max_pages]
|
|
52
52
|
value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_PAGES
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# @param next_url [Html2rss::Url, String, nil]
|
|
56
56
|
# @return [Boolean]
|
|
57
57
|
def follow_up_allowed?(next_url)
|
|
58
|
-
!next_url.
|
|
58
|
+
!next_url.to_s.empty? && !session.visited?(next_url)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# @param next_url [Html2rss::Url, String]
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# @param page_response [RequestService::Response]
|
|
12
12
|
# @param page_number [Integer]
|
|
13
13
|
# @return [String, nil]
|
|
14
|
-
def next_page_url(page_response, page_number:
|
|
14
|
+
def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
15
|
selector = config[:selector]
|
|
16
16
|
return nil if selector.nil? || selector.empty?
|
|
17
17
|
|
|
@@ -19,16 +19,16 @@ module Html2rss
|
|
|
19
19
|
return nil unless node
|
|
20
20
|
|
|
21
21
|
href = node['href'] || node.text
|
|
22
|
-
return nil if href.
|
|
22
|
+
return nil if href.strip.empty?
|
|
23
23
|
|
|
24
|
-
Html2rss::Url.from_relative(href
|
|
24
|
+
Html2rss::Url.from_relative(href, page_response.url)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# @param parsed_body [Nokogiri::HTML::Document]
|
|
28
28
|
# @param selector [String]
|
|
29
29
|
# @return [Nokogiri::XML::Node, nil]
|
|
30
30
|
def extract_node(parsed_body, selector)
|
|
31
|
-
parsed_body.at_css(selector)
|
|
31
|
+
parsed_body.at_css(selector)
|
|
32
32
|
rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
|
|
33
33
|
# Pure XPath like `descendant::a` fails LOOKS_LIKE_XPATH / at_css; fall back explicitly.
|
|
34
34
|
parsed_body.at_xpath(selector)
|
|
@@ -25,14 +25,8 @@ module Html2rss
|
|
|
25
25
|
# @return [Html2rss::Url]
|
|
26
26
|
def next_page_url(page_response, page_number:)
|
|
27
27
|
param = config.fetch(:param, DEFAULT_PARAM)
|
|
28
|
-
target_offset_val = target_offset_for(page_number)
|
|
29
28
|
url = Html2rss::Url.from_absolute(page_response.url)
|
|
30
|
-
|
|
31
|
-
if url.to_s.include?('{offset}')
|
|
32
|
-
Html2rss::Url.from_absolute(url.to_s.gsub('{offset}', target_offset_val.to_s))
|
|
33
|
-
else
|
|
34
|
-
url.with_query_values(url.query_values.merge(param.to_s => target_offset_val.to_s))
|
|
35
|
-
end
|
|
29
|
+
url.with_query_values(url.query_values.merge(param.to_s => target_offset_for(page_number).to_s))
|
|
36
30
|
end
|
|
37
31
|
|
|
38
32
|
# @param page_number [Integer]
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# @param page_response [RequestService::Response]
|
|
12
12
|
# @param page_number [Integer]
|
|
13
13
|
# @return [String, nil]
|
|
14
|
-
def next_page_url(page_response, page_number:
|
|
14
|
+
def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
15
|
href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
|
|
16
16
|
return nil if href.nil? || href.empty?
|
|
17
17
|
|
|
@@ -25,14 +25,8 @@ module Html2rss
|
|
|
25
25
|
# @return [Html2rss::Url]
|
|
26
26
|
def next_page_url(page_response, page_number:)
|
|
27
27
|
param = config.fetch(:param, DEFAULT_PARAM)
|
|
28
|
-
target_page_val = target_page_for(page_number)
|
|
29
28
|
url = Html2rss::Url.from_absolute(page_response.url)
|
|
30
|
-
|
|
31
|
-
if url.to_s.include?('{page}')
|
|
32
|
-
Html2rss::Url.from_absolute(url.to_s.gsub('{page}', target_page_val.to_s))
|
|
33
|
-
else
|
|
34
|
-
url.with_query_values(url.query_values.merge(param.to_s => target_page_val.to_s))
|
|
35
|
-
end
|
|
29
|
+
url.with_query_values(url.query_values.merge(param.to_s => target_page_for(page_number).to_s))
|
|
36
30
|
end
|
|
37
31
|
|
|
38
32
|
# @param page_number [Integer]
|
|
@@ -91,21 +91,17 @@ module Html2rss
|
|
|
91
91
|
|
|
92
92
|
private
|
|
93
93
|
|
|
94
|
-
def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/
|
|
94
|
+
def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/AbcSize
|
|
95
95
|
facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
|
|
96
96
|
obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
|
|
97
97
|
return if obs.hard_junk?(mode: hard_junk_mode)
|
|
98
98
|
|
|
99
99
|
quality, quality_parts = apply_rules(QUALITY_RULES, obs)
|
|
100
100
|
junk, junk_parts = apply_rules(JUNK_RULES, obs)
|
|
101
|
-
|
|
101
|
+
RankedSegment.new(
|
|
102
102
|
segment:,
|
|
103
|
-
score:
|
|
104
|
-
|
|
105
|
-
quality:,
|
|
106
|
-
junk:,
|
|
107
|
-
breakdown: quality_parts.merge(junk_parts)
|
|
108
|
-
)
|
|
103
|
+
score: Score.new(composite: (quality - junk).to_f, quality: quality.to_f, junk: junk.to_f,
|
|
104
|
+
breakdown: quality_parts.merge(junk_parts).freeze)
|
|
109
105
|
)
|
|
110
106
|
end
|
|
111
107
|
|
|
@@ -116,30 +112,6 @@ module Html2rss
|
|
|
116
112
|
end
|
|
117
113
|
[parts.values.sum, parts]
|
|
118
114
|
end
|
|
119
|
-
|
|
120
|
-
def build_score(composite:, quality: nil, junk: nil, breakdown: nil)
|
|
121
|
-
raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
|
|
122
|
-
|
|
123
|
-
Score.new(
|
|
124
|
-
composite: composite.to_f,
|
|
125
|
-
quality: (quality.nil? ? composite : quality).to_f,
|
|
126
|
-
junk: (junk || 0).to_f,
|
|
127
|
-
breakdown: normalize_breakdown(breakdown)
|
|
128
|
-
)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def normalize_breakdown(breakdown)
|
|
132
|
-
return Score::EMPTY_BREAKDOWN if breakdown.nil?
|
|
133
|
-
|
|
134
|
-
breakdown.transform_keys { |id| self.class.assert_feature_id!(id) }.freeze
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def ranked(segment:, score:)
|
|
138
|
-
raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
|
|
139
|
-
raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
|
|
140
|
-
|
|
141
|
-
RankedSegment.new(segment:, score:)
|
|
142
|
-
end
|
|
143
115
|
end
|
|
144
116
|
end
|
|
145
117
|
end
|
|
@@ -24,7 +24,6 @@ module Html2rss
|
|
|
24
24
|
def initialize(base_url)
|
|
25
25
|
@base_url = base_url
|
|
26
26
|
@by_href = {}
|
|
27
|
-
@by_node = {}.compare_by_identity
|
|
28
27
|
@text_classifier = LinkDestination::TextClassifier.new
|
|
29
28
|
end
|
|
30
29
|
|
|
@@ -35,15 +34,11 @@ module Html2rss
|
|
|
35
34
|
# @param node_or_href [SST::Node, String, #to_s]
|
|
36
35
|
# @return [LinkDestination::DestinationFacts, nil]
|
|
37
36
|
def destination_facts(node_or_href)
|
|
38
|
-
return @by_node[node_or_href] if node_or_href.is_a?(SST::Node) && @by_node.key?(node_or_href)
|
|
39
|
-
|
|
40
37
|
href = extract_href(node_or_href)
|
|
41
38
|
return unless href
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
facts
|
|
46
|
-
rescue ArgumentError
|
|
40
|
+
@by_href[href] ||= build_facts(href)
|
|
41
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
47
42
|
nil
|
|
48
43
|
end
|
|
49
44
|
|
|
@@ -54,11 +54,9 @@ module Html2rss
|
|
|
54
54
|
##
|
|
55
55
|
# Retrieves and returns the normalized absolute URL.
|
|
56
56
|
#
|
|
57
|
-
# @return [Html2rss::Url, nil] The absolute URL.
|
|
57
|
+
# @return [Html2rss::Url, nil] The absolute URL, or nil when nothing matched or the +href+ is blank.
|
|
58
58
|
def call
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
Url.from_relative(@href, @base_url)
|
|
59
|
+
Url.from_relative(@href, @base_url) unless @href.strip.empty?
|
|
62
60
|
end
|
|
63
61
|
end
|
|
64
62
|
end
|