html2rss 0.32.3 → 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 +54 -1
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
- 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 +3 -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_service/strategy.rb +2 -2
- data/lib/html2rss/request_service.rb +26 -2
- 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/error.rb
CHANGED
|
@@ -3,13 +3,65 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
# The Html2rss::Error base class.
|
|
5
5
|
class Error < StandardError
|
|
6
|
+
# Upstream transport_meta keys promoted into error diagnostics.
|
|
7
|
+
TRANSPORT_META_KEYS = %i[request_id strategy_used render_ms error_category timeout_phase].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
##
|
|
11
|
+
# Builds a sparse diagnostics hash from attempt hashes and optional error metadata.
|
|
12
|
+
#
|
|
13
|
+
# @param attempts [Array<Hash>, nil] tier attempt diagnostics
|
|
14
|
+
# @param transport_meta [Hash, nil] direct error transport metadata
|
|
15
|
+
# @param timeout_phase [String, nil] timeout stage when known
|
|
16
|
+
# @param surface_category [Symbol, nil] page surface classification when known
|
|
17
|
+
# @return [Hash{Symbol => Object}]
|
|
18
|
+
def diagnostics_from_attempts(attempts, transport_meta: nil, timeout_phase: nil, surface_category: nil)
|
|
19
|
+
list = Array(attempts)
|
|
20
|
+
meta = list.filter_map { |item| item[:transport_meta] || item['transport_meta'] }.last || transport_meta
|
|
21
|
+
fields = extract_transport_fields(meta)
|
|
22
|
+
fields[:timeout_phase] ||= timeout_phase
|
|
23
|
+
fields[:surface_category] = surface_category
|
|
24
|
+
{ strategy_attempts: (list if list.any?), **fields }.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def extract_transport_fields(meta)
|
|
30
|
+
return {} unless meta
|
|
31
|
+
|
|
32
|
+
TRANSPORT_META_KEYS.to_h { |key| [key, meta[key] || meta[key.to_s]] }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
6
36
|
# @return [Array<Hash{Symbol => Object}>, nil] tier attempt diagnostics when set by AutoFallback
|
|
7
37
|
attr_accessor :attempts
|
|
8
38
|
|
|
9
39
|
# @return [Hash{String => Object}, nil] allowlisted upstream transport telemetry when attached
|
|
10
40
|
attr_accessor :transport_meta
|
|
41
|
+
|
|
42
|
+
# @return [String, nil] scrape-api timeout stage when known (overridden by RequestTimedOut)
|
|
43
|
+
def timeout_phase; end
|
|
44
|
+
|
|
45
|
+
# @return [Symbol, nil] surface classification when known (overridden by NoFeedItemsExtracted)
|
|
46
|
+
def surface_category; end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Sparse telemetry hash for observability consumers.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash{Symbol => Object}]
|
|
52
|
+
def diagnostics
|
|
53
|
+
self.class.diagnostics_from_attempts(
|
|
54
|
+
attempts,
|
|
55
|
+
transport_meta:,
|
|
56
|
+
timeout_phase:,
|
|
57
|
+
surface_category:
|
|
58
|
+
)
|
|
59
|
+
end
|
|
11
60
|
end
|
|
12
61
|
|
|
62
|
+
# Raised when a caller-supplied option value is malformed or out of range.
|
|
63
|
+
class InvalidOption < Error; end
|
|
64
|
+
|
|
13
65
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
14
66
|
class NoFeedItemsExtracted < Error
|
|
15
67
|
# Categories that append shared surface guidance to the empty-feed message.
|
|
@@ -38,7 +90,8 @@ module Html2rss
|
|
|
38
90
|
|
|
39
91
|
def base_message
|
|
40
92
|
summaries = attempts.map do |attempt|
|
|
41
|
-
|
|
93
|
+
count = attempt[:items_count]
|
|
94
|
+
details = count.nil? ? "#{attempt[:error_class]} error" : "#{count} items"
|
|
42
95
|
"#{attempt[:strategy]} (#{details})"
|
|
43
96
|
end.join(', ')
|
|
44
97
|
|
|
@@ -37,7 +37,7 @@ module Html2rss
|
|
|
37
37
|
item_maker.title = article.title
|
|
38
38
|
item_maker.author = article.author
|
|
39
39
|
description = ItemPresentation.description_for(article)
|
|
40
|
-
item_maker.description = description if description
|
|
40
|
+
item_maker.description = description if description
|
|
41
41
|
item_maker.link = article.url.to_s if article.url
|
|
42
42
|
item_maker.pubDate = article.published_at&.rfc2822
|
|
43
43
|
end
|
|
@@ -37,7 +37,7 @@ Retries share the feed's request/session policy. Pin a concrete strategy when yo
|
|
|
37
37
|
|
|
38
38
|
### Terminal fallback outcome
|
|
39
39
|
|
|
40
|
-
A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut` or `
|
|
40
|
+
A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut`, `BlockedSurfaceDetected`, or `ConnectionFailed`). `AutoFallback` remembers that error across later hops and, when no later hop yields items, re-raises it with `attempts` attached. A later hop that extracts items still succeeds. Two zero-item hops with no terminal transport error still raise `NoFeedItemsExtracted`. True empty stays the web policy already in force: listing `EXTRACTION_EMPTY` is cacheable; `app_shell` and `high_entropy_surface` are not. `html2rss-web` `ErrorClassifier` maps the raised error to HTTP/cache policy; it does not invent a second cache rule for empty extraction.
|
|
41
41
|
|
|
42
42
|
## Success signals
|
|
43
43
|
|
|
@@ -32,7 +32,8 @@ module Html2rss
|
|
|
32
32
|
# decisions instead of EXTRACTION_EMPTY.
|
|
33
33
|
TERMINAL_TRANSPORT_ERRORS = [
|
|
34
34
|
RequestService::RequestTimedOut,
|
|
35
|
-
RequestService::BlockedSurfaceDetected
|
|
35
|
+
RequestService::BlockedSurfaceDetected,
|
|
36
|
+
RequestService::ConnectionFailed
|
|
36
37
|
].freeze
|
|
37
38
|
|
|
38
39
|
##
|
|
@@ -79,12 +80,11 @@ module Html2rss
|
|
|
79
80
|
|
|
80
81
|
# @param strategy [Symbol] strategy that raised
|
|
81
82
|
# @param error [Exception] caught error
|
|
83
|
+
# @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
|
|
82
84
|
# @return [void]
|
|
83
|
-
def record_error(strategy:, error:)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
attempt[:transport_meta] = meta if meta&.any?
|
|
87
|
-
@attempts << attempt
|
|
85
|
+
def record_error(strategy:, error:, elapsed_ms: nil)
|
|
86
|
+
meta = error.transport_meta if error.is_a?(Error)
|
|
87
|
+
append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
|
|
88
88
|
@last_error = error
|
|
89
89
|
return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
90
90
|
|
|
@@ -94,12 +94,11 @@ module Html2rss
|
|
|
94
94
|
# @param strategy [Symbol] strategy that returned a response
|
|
95
95
|
# @param items_count [Integer] extracted article count
|
|
96
96
|
# @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
|
|
97
|
+
# @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
|
|
97
98
|
# @return [void]
|
|
98
|
-
def record_items(strategy:, items_count:, transport_meta: nil)
|
|
99
|
+
def record_items(strategy:, items_count:, transport_meta: nil, elapsed_ms: nil)
|
|
99
100
|
@last_error = nil
|
|
100
|
-
|
|
101
|
-
attempt[:transport_meta] = transport_meta if transport_meta&.any?
|
|
102
|
-
@attempts << attempt
|
|
101
|
+
append_attempt(strategy:, items_count:, transport_meta:, elapsed_ms:)
|
|
103
102
|
end
|
|
104
103
|
|
|
105
104
|
# @param response [RequestService::Response] last response that reached extraction
|
|
@@ -131,6 +130,37 @@ module Html2rss
|
|
|
131
130
|
entry_resolution:
|
|
132
131
|
)
|
|
133
132
|
end
|
|
133
|
+
|
|
134
|
+
# Raises the terminal transport error, last error, or NoFeedItemsExtracted with attempts attached.
|
|
135
|
+
#
|
|
136
|
+
# @return [void] always raises
|
|
137
|
+
# @raise [Html2rss::Error, StandardError]
|
|
138
|
+
def raise_failure!
|
|
139
|
+
if (error = failure_error)
|
|
140
|
+
error.attempts = attempts if error.is_a?(Error)
|
|
141
|
+
raise error
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
response = @last_response
|
|
145
|
+
surface_category = PageRecon.surface_category_for(response:, url: response.url) if response
|
|
146
|
+
raise NoFeedItemsExtracted.new(attempts:, surface_category:)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def append_attempt(strategy:, items_count: nil, error_class: nil, transport_meta: nil, elapsed_ms: nil)
|
|
152
|
+
attempt = { strategy:, items_count:, error_class: }
|
|
153
|
+
attempt[:elapsed_ms] = elapsed_ms unless elapsed_ms.nil?
|
|
154
|
+
attempt[:transport_meta] = transport_meta if transport_meta&.any?
|
|
155
|
+
@attempts << attempt
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def failure_error
|
|
159
|
+
return @terminal_transport_error if @terminal_transport_error
|
|
160
|
+
return if @last_error.nil? || @last_error.is_a?(RequestService::BotasaurusConfigurationError)
|
|
161
|
+
|
|
162
|
+
@last_error
|
|
163
|
+
end
|
|
134
164
|
end
|
|
135
165
|
|
|
136
166
|
##
|
|
@@ -155,7 +185,7 @@ module Html2rss
|
|
|
155
185
|
state = run_attempts
|
|
156
186
|
return state.result if state.succeeded?
|
|
157
187
|
|
|
158
|
-
|
|
188
|
+
state.raise_failure!
|
|
159
189
|
end
|
|
160
190
|
|
|
161
191
|
private
|
|
@@ -171,37 +201,33 @@ module Html2rss
|
|
|
171
201
|
end
|
|
172
202
|
end
|
|
173
203
|
|
|
204
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- hop timing + NON_FALLBACK rescue stay co-located
|
|
174
205
|
def attempt(strategy:, next_strategy:, state:)
|
|
206
|
+
started_ms = monotonic_ms
|
|
175
207
|
request_session = pipeline.request_session_for(
|
|
176
208
|
config, strategy:, resources:, scrape_url: scrape_target.effective_url
|
|
177
209
|
)
|
|
178
|
-
response =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
request_session
|
|
189
|
-
rescue *NON_FALLBACK_ERRORS
|
|
190
|
-
raise
|
|
191
|
-
rescue StandardError => error
|
|
192
|
-
state.record_error(strategy:, error:)
|
|
193
|
-
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
|
|
194
|
-
nil
|
|
210
|
+
response = request_session.fetch_initial_response
|
|
211
|
+
process_response(
|
|
212
|
+
response:, strategy:, next_strategy:, request_session:, state:,
|
|
213
|
+
elapsed_ms: monotonic_ms - started_ms
|
|
214
|
+
)
|
|
215
|
+
rescue *NON_FALLBACK_ERRORS => error
|
|
216
|
+
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
217
|
+
state.abort!
|
|
218
|
+
rescue Html2rss::Error => error
|
|
219
|
+
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
220
|
+
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy && request_session
|
|
195
221
|
end
|
|
196
222
|
|
|
197
|
-
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
|
|
198
|
-
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
223
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists -- extract + resolution delegate
|
|
224
|
+
def process_response(response:, strategy:, next_strategy:, request_session:, state:, elapsed_ms: nil)
|
|
199
225
|
articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
|
|
200
226
|
config:, response:, request_session:
|
|
201
227
|
)
|
|
202
228
|
state.remember_response(response)
|
|
203
229
|
items_count = articles.size
|
|
204
|
-
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
230
|
+
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta, elapsed_ms:)
|
|
205
231
|
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
206
232
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
207
233
|
"budget_remaining=#{budget_remaining_label}")
|
|
@@ -249,30 +275,7 @@ module Html2rss
|
|
|
249
275
|
}.to_json)
|
|
250
276
|
end
|
|
251
277
|
|
|
252
|
-
def
|
|
253
|
-
error = failure_error(state)
|
|
254
|
-
if error
|
|
255
|
-
error.attempts = state.attempts if error.is_a?(Html2rss::Error)
|
|
256
|
-
raise error
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
response = state.last_response
|
|
260
|
-
raise NoFeedItemsExtracted.new(
|
|
261
|
-
attempts: state.attempts,
|
|
262
|
-
surface_category: (PageRecon.surface_category_for(response:, url: response.url) if response)
|
|
263
|
-
)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
# Prefer a remembered timeout/blocked-surface over a later zero-item hop so
|
|
267
|
-
# callers do not cache EXTRACTION_EMPTY for a transport failure.
|
|
268
|
-
def failure_error(state)
|
|
269
|
-
return state.terminal_transport_error if state.terminal_transport_error
|
|
270
|
-
|
|
271
|
-
error = state.last_error
|
|
272
|
-
return if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
|
|
273
|
-
|
|
274
|
-
error
|
|
275
|
-
end
|
|
278
|
+
def monotonic_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
276
279
|
|
|
277
280
|
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- shared payload then info/warn dispatch
|
|
278
281
|
def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
|
|
@@ -50,7 +50,6 @@ module Html2rss
|
|
|
50
50
|
return unless parsed_html
|
|
51
51
|
|
|
52
52
|
url = Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).first
|
|
53
|
-
return unless url
|
|
54
53
|
return unless eligible_url?(url)
|
|
55
54
|
|
|
56
55
|
url
|
|
@@ -86,6 +85,8 @@ module Html2rss
|
|
|
86
85
|
return [] unless parsed_html
|
|
87
86
|
|
|
88
87
|
sst = SST::Normalizer.call(parsed_html)
|
|
88
|
+
return [] unless sst
|
|
89
|
+
|
|
89
90
|
link_resolver = Scoring::LinkResolver.new(entry_url)
|
|
90
91
|
|
|
91
92
|
SEGMENT_STRATEGIES.each do |strategy|
|
|
@@ -93,8 +94,6 @@ module Html2rss
|
|
|
93
94
|
return urls if urls.size >= 2
|
|
94
95
|
end
|
|
95
96
|
[]
|
|
96
|
-
rescue StandardError
|
|
97
|
-
[]
|
|
98
97
|
end
|
|
99
98
|
|
|
100
99
|
def segment_urls_for(sst, strategy, link_resolver)
|
|
@@ -120,7 +119,7 @@ module Html2rss
|
|
|
120
119
|
return if href.nil? || href.empty? || href.start_with?('#', 'mailto:', 'javascript:')
|
|
121
120
|
|
|
122
121
|
Html2rss::Url.from_relative(href, entry_url)
|
|
123
|
-
rescue
|
|
122
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
124
123
|
nil
|
|
125
124
|
end
|
|
126
125
|
|
|
@@ -8,15 +8,11 @@ module Html2rss
|
|
|
8
8
|
# Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
|
|
9
9
|
class Options
|
|
10
10
|
##
|
|
11
|
-
# @param auto_source [Hash, nil]
|
|
11
|
+
# @param auto_source [Hash, nil] validated +auto_source+ config
|
|
12
12
|
# @return [Options]
|
|
13
13
|
def self.from_auto_source(auto_source)
|
|
14
|
-
raw = auto_source
|
|
15
|
-
raw
|
|
16
|
-
new(
|
|
17
|
-
enabled: raw.fetch(:enabled, FeedResolution::DEFAULT_CONFIG[:enabled]) != false,
|
|
18
|
-
max_probes: Integer(raw.fetch(:max_probes, FeedResolution::DEFAULT_CONFIG[:max_probes]))
|
|
19
|
-
)
|
|
14
|
+
raw = auto_source&.dig(:entry_resolution) || {}
|
|
15
|
+
new(enabled: raw.fetch(:enabled, true), max_probes: raw.fetch(:max_probes, DEFAULT_CONFIG[:max_probes]))
|
|
20
16
|
end
|
|
21
17
|
|
|
22
18
|
##
|
|
@@ -8,40 +8,36 @@ module Html2rss
|
|
|
8
8
|
# Minimum admitted articles before the entry URL is considered strong enough.
|
|
9
9
|
ARTICLE_FLOOR = 3
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
|
|
41
|
-
native * 2 >= articles.size
|
|
11
|
+
class << self
|
|
12
|
+
##
|
|
13
|
+
# @param config [Html2rss::Config]
|
|
14
|
+
# @param articles [Array<Html2rss::Article>] typed extract results (count derived)
|
|
15
|
+
# @param surface_category [Html2rss::SurfaceCategory, Symbol]
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def resolve?(config:, articles:, surface_category:)
|
|
18
|
+
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
19
|
+
return false unless eligible_config?(config)
|
|
20
|
+
|
|
21
|
+
category = SurfaceCategory[surface_category]
|
|
22
|
+
return false if category.blocked?
|
|
23
|
+
|
|
24
|
+
articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def eligible_config?(config)
|
|
30
|
+
return false unless config.auto_source
|
|
31
|
+
return false if config.selectors
|
|
32
|
+
|
|
33
|
+
Options.from_auto_source(config.auto_source).enabled?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def native_feed_majority?(articles)
|
|
37
|
+
native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
|
|
38
|
+
native * 2 >= articles.size
|
|
39
|
+
end
|
|
42
40
|
end
|
|
43
|
-
module_function :native_feed_majority?
|
|
44
|
-
private_class_method :native_feed_majority?
|
|
45
41
|
end
|
|
46
42
|
end
|
|
47
43
|
end
|
|
@@ -16,7 +16,7 @@ module Html2rss
|
|
|
16
16
|
# @param origin_url [String, Html2rss::Url]
|
|
17
17
|
def initialize(request_session:, origin_url:)
|
|
18
18
|
@request_session = request_session
|
|
19
|
-
@origin_url =
|
|
19
|
+
@origin_url = Url.from_absolute(origin_url)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
##
|
|
@@ -28,7 +28,7 @@ module Html2rss
|
|
|
28
28
|
relation: :entry_resolution,
|
|
29
29
|
origin_url:
|
|
30
30
|
)
|
|
31
|
-
score_response(
|
|
31
|
+
score_response(response)
|
|
32
32
|
rescue Html2rss::Error
|
|
33
33
|
nil
|
|
34
34
|
end
|
|
@@ -37,7 +37,7 @@ module Html2rss
|
|
|
37
37
|
|
|
38
38
|
attr_reader :request_session, :origin_url
|
|
39
39
|
|
|
40
|
-
def score_response(
|
|
40
|
+
def score_response(response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
|
|
41
41
|
if response.feed_response?
|
|
42
42
|
count = Syndication::Parser.parse_response(response).size
|
|
43
43
|
return Scored.new(
|
|
@@ -11,38 +11,38 @@ module Html2rss
|
|
|
11
11
|
# Bonus when the surface looks like a listing rather than a hub/shell.
|
|
12
12
|
LISTING_SURFACE_BONUS = 5
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
class << self
|
|
15
|
+
##
|
|
16
|
+
# @param articles_count [Integer]
|
|
17
|
+
# @return [Integer]
|
|
18
|
+
def score_feed(articles_count:)
|
|
19
|
+
articles_count * ARTICLE_WEIGHT
|
|
20
|
+
end
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
score = assessment.articles_count * ARTICLE_WEIGHT
|
|
28
|
-
score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
|
|
29
|
-
drops = assessment.admission_drops.values.sum
|
|
30
|
-
total = assessment.articles_count + drops
|
|
31
|
-
score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
|
|
32
|
-
score
|
|
33
|
-
end
|
|
22
|
+
##
|
|
23
|
+
# @param assessment [Html2rss::PageRecon::Assessment]
|
|
24
|
+
# @return [Integer]
|
|
25
|
+
def score_assessment(assessment)
|
|
26
|
+
score = assessment.articles_count * ARTICLE_WEIGHT
|
|
27
|
+
score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
|
|
28
|
+
drops = assessment.admission_drops.values.sum
|
|
29
|
+
total = assessment.articles_count + drops
|
|
30
|
+
score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
|
|
31
|
+
score
|
|
32
|
+
end
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
##
|
|
35
|
+
# @param scored [Array<Probe::Scored>]
|
|
36
|
+
# @param entry_articles_count [Integer]
|
|
37
|
+
# @return [Probe::Scored, nil]
|
|
38
|
+
def pick_winner(scored:, entry_articles_count:)
|
|
39
|
+
winner = scored.max_by(&:score)
|
|
40
|
+
return unless winner
|
|
41
|
+
return if winner.articles_count <= entry_articles_count.to_i
|
|
42
|
+
return if winner.score <= 0
|
|
44
43
|
|
|
45
|
-
|
|
44
|
+
winner
|
|
45
|
+
end
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -116,17 +116,13 @@ module Html2rss
|
|
|
116
116
|
return unless resolution.applied
|
|
117
117
|
|
|
118
118
|
effective = scrape_target.with_effective(resolution.scrape_url)
|
|
119
|
-
|
|
120
|
-
resolution, effective:
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
nil
|
|
119
|
+
ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(effective:)
|
|
124
120
|
rescue RequestService::RequestBudgetExceeded
|
|
125
121
|
log_budget_exhausted
|
|
126
122
|
nil
|
|
127
123
|
rescue *NON_FALLBACK_ERRORS
|
|
128
124
|
raise
|
|
129
|
-
rescue
|
|
125
|
+
rescue Html2rss::Error => error
|
|
130
126
|
log_retry_failed(error)
|
|
131
127
|
nil
|
|
132
128
|
end
|
|
@@ -158,7 +154,7 @@ module Html2rss
|
|
|
158
154
|
# @return [String, nil]
|
|
159
155
|
def channel_host
|
|
160
156
|
Url.from_absolute(config.url.to_s).host
|
|
161
|
-
rescue
|
|
157
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
162
158
|
nil
|
|
163
159
|
end
|
|
164
160
|
|
|
@@ -184,7 +180,7 @@ module Html2rss
|
|
|
184
180
|
)
|
|
185
181
|
end
|
|
186
182
|
|
|
187
|
-
def retry_with(
|
|
183
|
+
def retry_with(effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
|
|
188
184
|
retry_session = pipeline.request_session_for(
|
|
189
185
|
config, strategy:, resources:, scrape_url: effective.effective_url
|
|
190
186
|
)
|
|
@@ -220,7 +216,7 @@ module Html2rss
|
|
|
220
216
|
def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
|
|
221
217
|
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
222
218
|
|
|
223
|
-
@entry_url =
|
|
219
|
+
@entry_url = Url.from_absolute(entry_url)
|
|
224
220
|
@response = response
|
|
225
221
|
@session = session
|
|
226
222
|
@config = config
|
data/lib/html2rss/feed_result.rb
CHANGED
|
@@ -23,8 +23,7 @@ module Html2rss
|
|
|
23
23
|
def initialize(channel:, articles:, status:, stylesheets: [])
|
|
24
24
|
raise ArgumentError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
|
|
25
25
|
raise ArgumentError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
|
|
26
|
-
|
|
27
|
-
articles = Array(articles)
|
|
26
|
+
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
28
27
|
raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
|
|
29
28
|
|
|
30
29
|
@channel = channel
|
|
@@ -82,7 +81,9 @@ module Html2rss
|
|
|
82
81
|
end
|
|
83
82
|
|
|
84
83
|
def freeze_stylesheets(stylesheets)
|
|
85
|
-
Array(
|
|
84
|
+
raise ArgumentError, 'stylesheets must be an Array' unless stylesheets.is_a?(Array)
|
|
85
|
+
|
|
86
|
+
stylesheets.map do |sheet|
|
|
86
87
|
sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
|
|
87
88
|
value.is_a?(String) ? value.dup.freeze : value
|
|
88
89
|
end.freeze
|