html2rss 0.25.0 → 0.27.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 +99 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +140 -49
- 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 +47 -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 +41 -10
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +318 -0
- data/lib/html2rss/cli.rb +94 -17
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +79 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +46 -32
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +31 -1
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +40 -15
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
- data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
- data/lib/html2rss/html/article_extractor.rb +95 -17
- data/lib/html2rss/html/article_rules/category.rb +28 -11
- data/lib/html2rss/html/article_rules/date.rb +60 -6
- data/lib/html2rss/html/article_rules/description.rb +122 -0
- data/lib/html2rss/html/card_walk.rb +42 -0
- data/lib/html2rss/html/feed_link.rb +34 -0
- data/lib/html2rss/html/navigator.rb +18 -0
- data/lib/html2rss/html/sst_article_extractor.rb +147 -31
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +49 -32
- data/lib/html2rss/mcp/config_argument.rb +42 -0
- data/lib/html2rss/mcp/contract.rb +173 -0
- data/lib/html2rss/mcp/inspect.rb +241 -0
- data/lib/html2rss/mcp/outcome.rb +188 -0
- data/lib/html2rss/mcp/server.rb +421 -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 +261 -90
- data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
- data/lib/html2rss/request_service/policy.rb +2 -2
- data/lib/html2rss/request_service/response.rb +72 -7
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +10 -10
- 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/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +31 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +62 -7
- data/schema/html2rss-config.schema.json +428 -94
- metadata +65 -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
|
@@ -32,12 +32,6 @@ module Html2rss
|
|
|
32
32
|
# @return [Hash] the request specific options
|
|
33
33
|
attr_reader :request
|
|
34
34
|
|
|
35
|
-
# @return [Hash] browserless specific options
|
|
36
|
-
def browserless = request.fetch(:browserless, {})
|
|
37
|
-
|
|
38
|
-
# @return [Hash, nil] preload options for browserless requests
|
|
39
|
-
def browserless_preload = browserless[:preload]
|
|
40
|
-
|
|
41
35
|
# @return [Symbol] the request relation
|
|
42
36
|
attr_reader :relation
|
|
43
37
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'brotli'
|
|
3
4
|
require 'faraday'
|
|
4
5
|
require 'faraday/follow_redirects'
|
|
5
6
|
require 'faraday/gzip'
|
|
@@ -9,6 +10,7 @@ module Html2rss
|
|
|
9
10
|
##
|
|
10
11
|
# Strategy to use Faraday for the request.
|
|
11
12
|
# @see https://rubygems.org/gems/faraday
|
|
13
|
+
# rubocop:disable Metrics/ClassLength -- terminal redirect retry colocated with Faraday transport
|
|
12
14
|
class FaradayStrategy < Strategy
|
|
13
15
|
##
|
|
14
16
|
# Restores buffered streamed bytes so response middleware can process them.
|
|
@@ -36,7 +38,8 @@ module Html2rss
|
|
|
36
38
|
def perform_execute
|
|
37
39
|
deadline = request_deadline
|
|
38
40
|
@response_guard = ResponseGuard.new(policy: ctx.policy)
|
|
39
|
-
|
|
41
|
+
reset_redirect_tracking!
|
|
42
|
+
raw_response = request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
40
43
|
raw_response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(raw_response)
|
|
41
44
|
build_response(raw_response)
|
|
42
45
|
end
|
|
@@ -47,8 +50,48 @@ module Html2rss
|
|
|
47
50
|
monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
def reset_redirect_tracking!
|
|
54
|
+
@last_redirect_to = nil
|
|
55
|
+
@terminal_redirect_retried = false
|
|
56
|
+
@request_url_override = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
60
|
+
faraday_request(response_guard, deadline:, streaming_buffer: true)
|
|
61
|
+
rescue Faraday::FollowRedirects::RedirectLimitReached => error
|
|
62
|
+
raise error unless terminal_redirect_retryable?
|
|
63
|
+
|
|
64
|
+
retry_from_terminal_redirect!(response_guard, deadline:)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def terminal_redirect_retryable?
|
|
68
|
+
return false if @terminal_redirect_retried || @last_redirect_to.nil?
|
|
69
|
+
|
|
70
|
+
@last_redirect_to.to_s != request_url.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def retry_from_terminal_redirect!(response_guard, deadline:)
|
|
74
|
+
terminal_url = @last_redirect_to
|
|
75
|
+
@terminal_redirect_retried = true
|
|
76
|
+
Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
|
|
77
|
+
begin_terminal_url_request!(terminal_url)
|
|
78
|
+
faraday_request(response_guard, deadline:, streaming_buffer: true, consume_budget: false)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def begin_terminal_url_request!(terminal_url)
|
|
82
|
+
ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
83
|
+
@request_url_override = terminal_url
|
|
84
|
+
@client = nil
|
|
85
|
+
@last_redirect_to = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def request_url
|
|
89
|
+
@request_url_override || ctx.url
|
|
90
|
+
end
|
|
91
|
+
|
|
50
92
|
def build_response(response)
|
|
51
|
-
Response.new(body: response.body, headers: response.headers
|
|
93
|
+
Response.new(body: CompressedBody.decode(response.body, headers: response.headers),
|
|
94
|
+
headers: response.headers, url: response_url(response),
|
|
52
95
|
status: response.status)
|
|
53
96
|
end
|
|
54
97
|
|
|
@@ -105,7 +148,7 @@ module Html2rss
|
|
|
105
148
|
|
|
106
149
|
# rubocop:disable Metrics/AbcSize
|
|
107
150
|
def client
|
|
108
|
-
@client ||= Faraday.new(url:
|
|
151
|
+
@client ||= Faraday.new(url: request_url.to_s, headers: ctx.headers) do |faraday|
|
|
109
152
|
faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
|
|
110
153
|
faraday.request :gzip
|
|
111
154
|
faraday.use StreamingBodyMiddleware
|
|
@@ -168,6 +211,7 @@ module Html2rss
|
|
|
168
211
|
lambda do |old_env, new_env|
|
|
169
212
|
from_url = normalize_url(old_env[:url])
|
|
170
213
|
to_url = normalize_url(new_env[:url])
|
|
214
|
+
@last_redirect_to = to_url
|
|
171
215
|
ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
172
216
|
end
|
|
173
217
|
end
|
|
@@ -180,5 +224,6 @@ module Html2rss
|
|
|
180
224
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
181
225
|
end
|
|
182
226
|
end
|
|
227
|
+
# rubocop:enable Metrics/ClassLength
|
|
183
228
|
end
|
|
184
229
|
end
|
|
@@ -18,7 +18,7 @@ module Html2rss
|
|
|
18
18
|
connect_timeout_seconds: Integer(ENV.fetch('HTML2RSS_CONNECT_TIMEOUT_SECONDS', 5)),
|
|
19
19
|
read_timeout_seconds: Integer(ENV.fetch('HTML2RSS_READ_TIMEOUT_SECONDS', 10)),
|
|
20
20
|
total_timeout_seconds: Integer(ENV.fetch('HTML2RSS_TOTAL_TIMEOUT_SECONDS', 30)),
|
|
21
|
-
max_redirects:
|
|
21
|
+
max_redirects: 5,
|
|
22
22
|
max_response_bytes: 5_242_880,
|
|
23
23
|
max_decompressed_bytes: 10_485_760,
|
|
24
24
|
max_requests: 1,
|
|
@@ -115,7 +115,7 @@ module Html2rss
|
|
|
115
115
|
# @raise [UnsupportedUrlScheme] if the redirect downgrades from HTTPS to HTTP
|
|
116
116
|
def validate_redirect!(from_url:, to_url:, origin_url:, relation:)
|
|
117
117
|
if from_url.scheme == 'https' && to_url.scheme == 'http'
|
|
118
|
-
raise UnsupportedUrlScheme,
|
|
118
|
+
raise UnsupportedUrlScheme, "Redirect downgraded from #{from_url} to #{to_url}"
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
validate_request!(url: to_url, origin_url:, relation:)
|
|
@@ -9,6 +9,16 @@ module Html2rss
|
|
|
9
9
|
class Response
|
|
10
10
|
# Default when a strategy does not attach transport telemetry.
|
|
11
11
|
EMPTY_TRANSPORT_META = {}.freeze
|
|
12
|
+
# Default when a strategy does not capture sub-resource responses.
|
|
13
|
+
EMPTY_CAPTURED_RESPONSES = [].freeze
|
|
14
|
+
|
|
15
|
+
# Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
|
|
16
|
+
HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
|
|
17
|
+
# Charset from Content-Type or a leading <meta charset>.
|
|
18
|
+
CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
|
|
19
|
+
# Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
|
|
20
|
+
META_CHARSET_BYTES = 2048
|
|
21
|
+
private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES
|
|
12
22
|
|
|
13
23
|
##
|
|
14
24
|
# @param body [String] the body of the response
|
|
@@ -16,7 +26,10 @@ module Html2rss
|
|
|
16
26
|
# @param headers [Hash] the headers of the response
|
|
17
27
|
# @param status [Integer, nil] the HTTP status code when available
|
|
18
28
|
# @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
|
|
19
|
-
|
|
29
|
+
# @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
|
|
30
|
+
# rubocop:disable Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
|
|
31
|
+
def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
|
|
32
|
+
captured_responses: EMPTY_CAPTURED_RESPONSES)
|
|
20
33
|
@body = body
|
|
21
34
|
|
|
22
35
|
headers = headers.dup
|
|
@@ -27,7 +40,13 @@ module Html2rss
|
|
|
27
40
|
@status = status
|
|
28
41
|
@url = url
|
|
29
42
|
@transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
|
|
43
|
+
@captured_responses = if captured_responses.nil? || captured_responses.empty?
|
|
44
|
+
EMPTY_CAPTURED_RESPONSES
|
|
45
|
+
else
|
|
46
|
+
captured_responses.freeze
|
|
47
|
+
end
|
|
30
48
|
end
|
|
49
|
+
# rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
|
|
31
50
|
|
|
32
51
|
# @return [String] the raw body of the response
|
|
33
52
|
attr_reader :body
|
|
@@ -44,24 +63,26 @@ module Html2rss
|
|
|
44
63
|
# @return [Hash] allowlisted upstream transport telemetry
|
|
45
64
|
attr_reader :transport_meta
|
|
46
65
|
|
|
66
|
+
# @return [Array<Hash>] captured JSON XHR/fetch responses (empty when unsupported)
|
|
67
|
+
attr_reader :captured_responses
|
|
68
|
+
|
|
47
69
|
# @return [String] normalized content type header value
|
|
48
70
|
def content_type = header('content-type').to_s
|
|
49
71
|
|
|
50
72
|
# @return [Boolean] whether response content is JSON
|
|
51
73
|
def json_response? = content_type.include?('application/json')
|
|
52
74
|
|
|
53
|
-
# @return [Boolean] whether response content is HTML
|
|
54
|
-
def html_response?
|
|
75
|
+
# @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
|
|
76
|
+
def html_response?
|
|
77
|
+
content_type.include?('text/html') || (!json_response? && html_looking_body?)
|
|
78
|
+
end
|
|
55
79
|
|
|
56
80
|
##
|
|
57
81
|
# @return [Nokogiri::HTML::Document, Hash] the parsed body of the response, frozen object
|
|
58
82
|
# @raise [UnsupportedResponseContentType] if the content type is not supported
|
|
59
83
|
def parsed_body
|
|
60
84
|
@parsed_body ||= if html_response?
|
|
61
|
-
|
|
62
|
-
# Remove comments from the document to avoid processing irrelevant content
|
|
63
|
-
doc.xpath('//comment()').each(&:remove)
|
|
64
|
-
end.freeze
|
|
85
|
+
parse_html_document
|
|
65
86
|
elsif json_response?
|
|
66
87
|
JSON.parse(body, symbolize_names: true).freeze
|
|
67
88
|
else
|
|
@@ -78,6 +99,50 @@ module Html2rss
|
|
|
78
99
|
headers.find { |key, _value| key.casecmp?(name) }&.last
|
|
79
100
|
end
|
|
80
101
|
end
|
|
102
|
+
|
|
103
|
+
def parse_html_document
|
|
104
|
+
Nokogiri::HTML(decoded_html_body).tap do |doc|
|
|
105
|
+
doc.xpath('//comment()').each(&:remove)
|
|
106
|
+
end.freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def decoded_html_body
|
|
110
|
+
bytes = body.to_s.b
|
|
111
|
+
transcode_html(bytes, header_charset || meta_charset(bytes))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def transcode_html(bytes, charset)
|
|
115
|
+
encoding = html_encoding_for(charset)
|
|
116
|
+
return utf8_scrub(bytes) unless encoding
|
|
117
|
+
|
|
118
|
+
bytes.dup.force_encoding(encoding).encode(Encoding::UTF_8)
|
|
119
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
120
|
+
utf8_scrub(bytes)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def html_encoding_for(charset)
|
|
124
|
+
return if charset.nil? || utf8_charset?(charset)
|
|
125
|
+
|
|
126
|
+
Encoding.find(charset)
|
|
127
|
+
rescue ArgumentError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def utf8_charset?(charset) = charset.to_s.downcase.gsub(/[\s_-]/, '') == 'utf8'
|
|
132
|
+
|
|
133
|
+
def utf8_scrub(bytes) = bytes.dup.force_encoding(Encoding::UTF_8).scrub
|
|
134
|
+
|
|
135
|
+
def header_charset
|
|
136
|
+
content_type[CHARSET_PARAMETER, 1]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def meta_charset(bytes)
|
|
140
|
+
bytes.byteslice(0, META_CHARSET_BYTES).to_s[CHARSET_PARAMETER, 1]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def html_looking_body?
|
|
144
|
+
body.to_s.b.match?(HTML_BODY_SNIFF)
|
|
145
|
+
end
|
|
81
146
|
end
|
|
82
147
|
end
|
|
83
148
|
end
|
|
@@ -137,8 +137,7 @@ module Html2rss
|
|
|
137
137
|
# @return [Boolean]
|
|
138
138
|
def timeout_error?(error)
|
|
139
139
|
error.is_a?(Faraday::TimeoutError) ||
|
|
140
|
-
error.is_a?(Timeout::Error)
|
|
141
|
-
(defined?(Puppeteer::TimeoutError) && error.is_a?(Puppeteer::TimeoutError))
|
|
140
|
+
error.is_a?(Timeout::Error)
|
|
142
141
|
end
|
|
143
142
|
|
|
144
143
|
# @param error [StandardError]
|
|
@@ -6,7 +6,7 @@ require 'forwardable'
|
|
|
6
6
|
module Html2rss
|
|
7
7
|
##
|
|
8
8
|
# Requests website URLs to retrieve their HTML for further processing.
|
|
9
|
-
# Provides concrete transport strategies (e.g. Faraday,
|
|
9
|
+
# Provides concrete transport strategies (e.g. Faraday, Botasaurus).
|
|
10
10
|
#
|
|
11
11
|
# Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
|
|
12
12
|
# it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
|
|
@@ -23,8 +23,6 @@ module Html2rss
|
|
|
23
23
|
class UnsupportedResponseContentType < Html2rss::Error; end
|
|
24
24
|
# Raised when HTTP request slot limits are exceeded.
|
|
25
25
|
class RequestBudgetExceeded < Html2rss::Error; end
|
|
26
|
-
# Raised when Browserless preload interaction limits are exceeded.
|
|
27
|
-
class InteractionBudgetExceeded < Html2rss::Error; end
|
|
28
26
|
# Raised when policy denies private-network access.
|
|
29
27
|
class PrivateNetworkDenied < Html2rss::Error; end
|
|
30
28
|
# Raised when cross-origin follow-up requests are denied.
|
|
@@ -35,14 +33,17 @@ module Html2rss
|
|
|
35
33
|
class BlockedSurfaceDetected < Html2rss::Error; end
|
|
36
34
|
# Raised when a request times out.
|
|
37
35
|
class RequestTimedOut < Html2rss::Error; end
|
|
38
|
-
|
|
39
|
-
class BrowserlessConfigurationError < Html2rss::Error; end
|
|
40
|
-
# Raised when Browserless cannot be reached.
|
|
41
|
-
class BrowserlessConnectionFailed < Html2rss::Error; end
|
|
36
|
+
|
|
42
37
|
# Raised when Botasaurus configuration is missing or invalid.
|
|
43
|
-
class BotasaurusConfigurationError < Html2rss::Error
|
|
44
|
-
|
|
38
|
+
class BotasaurusConfigurationError < Html2rss::Error
|
|
39
|
+
# Short empty-feed guidance owned once (composed by {Html2rss::NoFeedItemsExtracted}).
|
|
40
|
+
EMPTY_FEED_HINT = 'Configure BOTASAURUS_SCRAPER_URL to enable the Botasaurus request strategy.'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised when the Botasaurus service cannot be reached (network / DNS / SSL).
|
|
45
44
|
class BotasaurusConnectionFailed < Html2rss::Error; end
|
|
45
|
+
# Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
|
|
46
|
+
class BotasaurusServiceError < Html2rss::Error; end
|
|
46
47
|
|
|
47
48
|
class << self
|
|
48
49
|
extend Forwardable
|
|
@@ -62,7 +63,6 @@ module Html2rss
|
|
|
62
63
|
@strategies = {
|
|
63
64
|
faraday: FaradayStrategy,
|
|
64
65
|
botasaurus: BotasaurusStrategy,
|
|
65
|
-
browserless: BrowserlessStrategy,
|
|
66
66
|
local_file: LocalFileStrategy
|
|
67
67
|
}
|
|
68
68
|
@default_strategy_name = :faraday
|
|
@@ -8,21 +8,15 @@ module Html2rss
|
|
|
8
8
|
# Microdata itemprop values treated as publish/update markers.
|
|
9
9
|
PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
|
|
10
10
|
|
|
11
|
-
# Matches content-like tokens in class/id strings.
|
|
12
|
-
CONTENT_TOKEN_REGEXP = begin
|
|
13
|
-
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
|
|
14
|
-
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
15
|
-
end.freeze
|
|
16
|
-
|
|
17
|
-
# Matches utility/junk tokens in class/id strings.
|
|
18
|
-
JUNK_TOKEN_REGEXP = begin
|
|
19
|
-
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
|
|
20
|
-
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
21
|
-
end.freeze
|
|
22
|
-
|
|
23
11
|
# @param text_classifier [LinkDestination::TextClassifier]
|
|
24
|
-
|
|
12
|
+
# @param content_token_regexp [Regexp]
|
|
13
|
+
# @param junk_token_regexp [Regexp]
|
|
14
|
+
def initialize(text_classifier: LinkDestination::TextClassifier.new,
|
|
15
|
+
content_token_regexp: LinkResolver::CONTENT_TOKEN_REGEXP,
|
|
16
|
+
junk_token_regexp: LinkResolver::JUNK_TOKEN_REGEXP)
|
|
25
17
|
@text_classifier = text_classifier
|
|
18
|
+
@content_token_regexp = content_token_regexp
|
|
19
|
+
@junk_token_regexp = junk_token_regexp
|
|
26
20
|
end
|
|
27
21
|
|
|
28
22
|
##
|
|
@@ -41,8 +35,8 @@ module Html2rss
|
|
|
41
35
|
publish_marker: publish_marker?(container),
|
|
42
36
|
descriptive_context: descriptive_context?(container.visible_text, title),
|
|
43
37
|
article_container: container.name == :article,
|
|
44
|
-
content_tokens: tokens.match?(
|
|
45
|
-
junk_tokens: tokens.match?(
|
|
38
|
+
content_tokens: tokens.match?(@content_token_regexp),
|
|
39
|
+
junk_tokens: tokens.match?(@junk_token_regexp),
|
|
46
40
|
utility_prefix_title: @text_classifier.utility_prefix?(title),
|
|
47
41
|
recommended_title: @text_classifier.recommended?(title),
|
|
48
42
|
utility_path: destination_facts&.utility_path,
|
|
@@ -10,7 +10,17 @@ module Html2rss
|
|
|
10
10
|
# Minimum composite score retained by {#rank_top} (precision floor).
|
|
11
11
|
SCORE_FLOOR = 0.0
|
|
12
12
|
|
|
13
|
-
|
|
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
|
|
21
|
+
private_constant :FEATURE_IDS
|
|
22
|
+
|
|
23
|
+
# Quality feature triples: [feature_id, predicate, weight].
|
|
14
24
|
QUALITY_RULES = [
|
|
15
25
|
[:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
|
|
16
26
|
[:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
|
|
@@ -22,7 +32,7 @@ module Html2rss
|
|
|
22
32
|
[:content_tokens, lambda(&:content_tokens), 10]
|
|
23
33
|
].freeze
|
|
24
34
|
|
|
25
|
-
# Junk feature triples: [
|
|
35
|
+
# Junk feature triples: [feature_id, predicate, weight].
|
|
26
36
|
JUNK_RULES = [
|
|
27
37
|
[:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
|
|
28
38
|
[:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
|
|
@@ -33,7 +43,17 @@ module Html2rss
|
|
|
33
43
|
[:junk_tokens, lambda(&:junk_tokens), 15]
|
|
34
44
|
].freeze
|
|
35
45
|
|
|
36
|
-
|
|
46
|
+
##
|
|
47
|
+
# @param id [Symbol]
|
|
48
|
+
# @return [Symbol]
|
|
49
|
+
# @raise [ArgumentError] when id is not in the closed set
|
|
50
|
+
def self.assert_feature_id!(id)
|
|
51
|
+
raise ArgumentError, "unknown feature: #{id.inspect}" unless FEATURE_IDS.include?(id)
|
|
52
|
+
|
|
53
|
+
id
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
(QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| assert_feature_id!(feature_id) }
|
|
37
57
|
|
|
38
58
|
# @param link_resolver [LinkResolver]
|
|
39
59
|
def initialize(link_resolver:)
|
|
@@ -78,9 +98,9 @@ module Html2rss
|
|
|
78
98
|
|
|
79
99
|
quality, quality_parts = apply_rules(QUALITY_RULES, obs)
|
|
80
100
|
junk, junk_parts = apply_rules(JUNK_RULES, obs)
|
|
81
|
-
|
|
101
|
+
ranked(
|
|
82
102
|
segment:,
|
|
83
|
-
score:
|
|
103
|
+
score: build_score(
|
|
84
104
|
composite: quality - junk,
|
|
85
105
|
quality:,
|
|
86
106
|
junk:,
|
|
@@ -96,6 +116,30 @@ module Html2rss
|
|
|
96
116
|
end
|
|
97
117
|
[parts.values.sum, parts]
|
|
98
118
|
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
|
|
99
143
|
end
|
|
100
144
|
end
|
|
101
145
|
end
|
|
@@ -8,6 +8,18 @@ module Html2rss
|
|
|
8
8
|
# Captures the href portion before a fragment for memoization keys.
|
|
9
9
|
HREF_BASE_PATTERN = /\A([^#]*)/
|
|
10
10
|
|
|
11
|
+
# Matches content-like tokens in class/id strings (from PathClassifier vocabulary).
|
|
12
|
+
CONTENT_TOKEN_REGEXP = begin
|
|
13
|
+
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
|
|
14
|
+
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
15
|
+
end.freeze
|
|
16
|
+
|
|
17
|
+
# Matches utility/junk tokens in class/id strings (from PathClassifier vocabulary).
|
|
18
|
+
JUNK_TOKEN_REGEXP = begin
|
|
19
|
+
words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
|
|
20
|
+
/(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
|
|
21
|
+
end.freeze
|
|
22
|
+
|
|
11
23
|
# @param base_url [String, Html2rss::Url]
|
|
12
24
|
def initialize(base_url)
|
|
13
25
|
@base_url = base_url
|
|
@@ -5,18 +5,6 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# A segment paired with its composite score.
|
|
7
7
|
RankedSegment = Data.define(:segment, :score) do
|
|
8
|
-
##
|
|
9
|
-
# @param segment [Html2rss::AutoSource::Segment]
|
|
10
|
-
# @param score [Score]
|
|
11
|
-
# @return [RankedSegment]
|
|
12
|
-
# @raise [ArgumentError] on invalid types
|
|
13
|
-
def self.build(segment:, score:)
|
|
14
|
-
raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
|
|
15
|
-
raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
|
|
16
|
-
|
|
17
|
-
new(segment:, score:)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
8
|
##
|
|
21
9
|
# @return [SST::Node]
|
|
22
10
|
def root_node = segment.root_node
|
|
@@ -4,26 +4,7 @@ module Html2rss
|
|
|
4
4
|
module Scoring
|
|
5
5
|
##
|
|
6
6
|
# Composite score with quality/junk split and optional typed feature breakdown.
|
|
7
|
-
Score = Data.define(:composite, :quality, :junk, :breakdown)
|
|
8
|
-
##
|
|
9
|
-
# @param composite [Numeric]
|
|
10
|
-
# @param quality [Numeric, nil]
|
|
11
|
-
# @param junk [Numeric, nil]
|
|
12
|
-
# @param breakdown [Hash{Symbol => Numeric}, nil]
|
|
13
|
-
# @return [Score]
|
|
14
|
-
# @raise [ArgumentError] when composite is not numeric
|
|
15
|
-
def self.build(composite:, quality: nil, junk: nil, breakdown: nil)
|
|
16
|
-
raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
|
|
17
|
-
|
|
18
|
-
parts = breakdown.nil? ? Score::EMPTY_BREAKDOWN : breakdown.transform_keys { FeatureId.assert!(_1) }.freeze
|
|
19
|
-
new(
|
|
20
|
-
composite: composite.to_f,
|
|
21
|
-
quality: (quality.nil? ? composite : quality).to_f,
|
|
22
|
-
junk: (junk || 0).to_f,
|
|
23
|
-
breakdown: parts
|
|
24
|
-
)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
7
|
+
Score = Data.define(:composite, :quality, :junk, :breakdown)
|
|
27
8
|
# Shared empty feature breakdown for scores without per-feature tallies.
|
|
28
9
|
Score::EMPTY_BREAKDOWN = {}.freeze
|
|
29
10
|
end
|
data/lib/html2rss/scoring.rb
CHANGED
|
@@ -4,30 +4,5 @@ module Html2rss
|
|
|
4
4
|
##
|
|
5
5
|
# Declarative feature scoring for AutoSource SST segments.
|
|
6
6
|
module Scoring
|
|
7
|
-
##
|
|
8
|
-
# Closed set of feature identifiers used by the scoring registry.
|
|
9
|
-
module FeatureId
|
|
10
|
-
# Closed set of allowed feature identifier symbols.
|
|
11
|
-
IDS = %i[
|
|
12
|
-
title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
|
|
13
|
-
publish_marker descriptive_context article_container content_tokens
|
|
14
|
-
non_content_utility_path utility_prefix_title_short shallow weak_container
|
|
15
|
-
recommended_title_non_content high_confidence_junk_path junk_tokens
|
|
16
|
-
heading_anchor heading_text_match meaningful_text content_like_destination
|
|
17
|
-
cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
|
|
18
|
-
].to_set.freeze
|
|
19
|
-
|
|
20
|
-
module_function
|
|
21
|
-
|
|
22
|
-
##
|
|
23
|
-
# @param id [Symbol]
|
|
24
|
-
# @return [Symbol]
|
|
25
|
-
# @raise [ArgumentError] when id is not in the closed set
|
|
26
|
-
def assert!(id)
|
|
27
|
-
raise ArgumentError, "unknown FeatureId: #{id.inspect}" unless IDS.include?(id)
|
|
28
|
-
|
|
29
|
-
id
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
7
|
end
|
|
33
8
|
end
|
|
@@ -24,9 +24,24 @@ module Html2rss
|
|
|
24
24
|
# In case you're extracting a date or a time, consider parsing it
|
|
25
25
|
# during post processing with {PostProcessors::ParseTime}.
|
|
26
26
|
class Attribute
|
|
27
|
+
# Config-facing option types (excluding shared selector fields supplied at runtime).
|
|
28
|
+
OPTION_TYPES = { attribute: String }.freeze
|
|
29
|
+
|
|
27
30
|
# The available options for the attribute extractor.
|
|
28
31
|
Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true)
|
|
29
32
|
|
|
33
|
+
# JSON Schema description exported via +schema_doc+.
|
|
34
|
+
DESCRIPTION = 'Return the value of an HTML attribute on the selected element. ' \
|
|
35
|
+
'Requires sibling selector option `attribute` (attribute name).'
|
|
36
|
+
|
|
37
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
38
|
+
EXAMPLES = [
|
|
39
|
+
'attribute'
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
43
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :attribute, klass: self)
|
|
44
|
+
|
|
30
45
|
##
|
|
31
46
|
# Initializes the Attribute extractor.
|
|
32
47
|
#
|
|
@@ -27,6 +27,18 @@ module Html2rss
|
|
|
27
27
|
# The available options for the href (attribute) extractor.
|
|
28
28
|
Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true)
|
|
29
29
|
|
|
30
|
+
# JSON Schema description exported via +schema_doc+.
|
|
31
|
+
DESCRIPTION = 'Return the absolute URL from the selected element\'s `href` attribute ' \
|
|
32
|
+
'(relative hrefs are resolved against the channel URL).'
|
|
33
|
+
|
|
34
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
35
|
+
EXAMPLES = [
|
|
36
|
+
'href'
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
40
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :href, klass: self)
|
|
41
|
+
|
|
30
42
|
##
|
|
31
43
|
# Initializes the Href extractor.
|
|
32
44
|
#
|
|
@@ -26,6 +26,18 @@ module Html2rss
|
|
|
26
26
|
# The available options for the html extractor.
|
|
27
27
|
Options = Struct.new('HtmlOptions', :selector, keyword_init: true)
|
|
28
28
|
|
|
29
|
+
# JSON Schema description exported via +schema_doc+.
|
|
30
|
+
DESCRIPTION = 'Return the outer HTML of the selected element. ' \
|
|
31
|
+
'Sanitize during post-processing (e.g. `sanitize_html`).'
|
|
32
|
+
|
|
33
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
34
|
+
EXAMPLES = [
|
|
35
|
+
'html'
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
39
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :html, klass: self)
|
|
40
|
+
|
|
29
41
|
##
|
|
30
42
|
# Initializes the Html extractor.
|
|
31
43
|
#
|
|
@@ -16,9 +16,23 @@ module Html2rss
|
|
|
16
16
|
# Would return:
|
|
17
17
|
# 'Foobar'
|
|
18
18
|
class Static
|
|
19
|
+
# Config-facing option types (excluding shared selector fields supplied at runtime).
|
|
20
|
+
OPTION_TYPES = { static: String }.freeze
|
|
21
|
+
|
|
19
22
|
# The available option for the static extractor.
|
|
20
23
|
Options = Struct.new('StaticOptions', :static, keyword_init: true)
|
|
21
24
|
|
|
25
|
+
# JSON Schema description exported via +schema_doc+.
|
|
26
|
+
DESCRIPTION = 'Return a fixed value from sibling selector option `static` (no DOM read).'
|
|
27
|
+
|
|
28
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
29
|
+
EXAMPLES = [
|
|
30
|
+
'static'
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
34
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :static, klass: self)
|
|
35
|
+
|
|
22
36
|
##
|
|
23
37
|
# Initializes the Static extractor.
|
|
24
38
|
#
|
|
@@ -24,6 +24,17 @@ module Html2rss
|
|
|
24
24
|
# The available options for the text extractor.
|
|
25
25
|
Options = Struct.new('TextOptions', :selector, keyword_init: true)
|
|
26
26
|
|
|
27
|
+
# JSON Schema description exported via +schema_doc+.
|
|
28
|
+
DESCRIPTION = 'Return collapsed visible text of the selected element (default extractor).'
|
|
29
|
+
|
|
30
|
+
# Example extractor name values for JSON Schema +examples+.
|
|
31
|
+
EXAMPLES = [
|
|
32
|
+
'text'
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
|
|
36
|
+
def self.schema_doc = SchemaDoc.for_extractor(name: :text, klass: self)
|
|
37
|
+
|
|
27
38
|
##
|
|
28
39
|
# Initializes the Text extractor.
|
|
29
40
|
#
|