html2rss 0.32.1 → 0.32.3
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/auto_source/scraper/meta_oembed.rb +7 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +19 -4
- data/lib/html2rss/auto_source/scraper/schema.rb +6 -2
- data/lib/html2rss/auto_source/scraper/sitemap.rb +19 -4
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +6 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +20 -3
- data/lib/html2rss/auto_source.rb +6 -1
- data/lib/html2rss/feed_pipeline/README.md +4 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +62 -46
- data/lib/html2rss/feed_resolution.rb +7 -4
- data/lib/html2rss/request_service/httpx_strategy.rb +34 -15
- data/lib/html2rss/request_service/strategy.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +20 -4
- data/lib/html2rss/syndication/discovery.rb +5 -1
- data/lib/html2rss/syndication/parser.rb +6 -2
- data/lib/html2rss/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d37540b2f300af8a1db631e16cf6cabf4cfa76069000a71c7723737225bd6c3
|
|
4
|
+
data.tar.gz: bd8f656ae629587336257d790da183c759b9c709c1ba411e5c0041156d35dd6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 802b88abad9bbadfa6753a5c62694a66c6eec3774e84d85191beabdfdd2bc8df3e37fdf325bc14c1d6fda0b90528aca2484df15d0d985b6e328c07f4c27a49b9
|
|
7
|
+
data.tar.gz: 827d398c34732c10600ced1002525702392448fcb4e8b585f7c27048c8fae38e9c2652307705c8a91e82fba295dbbfa8bfb0cc4dc3a71c4d70cdf9773722a4cf
|
|
@@ -116,6 +116,7 @@ module Html2rss
|
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
# @return [Hash{Symbol => Object}] oEmbed fields hash
|
|
119
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- structured warn keeps fields local
|
|
119
120
|
def fetch_oembed_data
|
|
120
121
|
return {} unless request_session && (link_node = oembed_link_node)
|
|
121
122
|
return {} unless (oembed_url = resolve_url(link_node['href']))
|
|
@@ -123,7 +124,12 @@ module Html2rss
|
|
|
123
124
|
response = request_session.follow_up(url: oembed_url, relation: :auto_source, origin_url: url)
|
|
124
125
|
parse_oembed_response(response)
|
|
125
126
|
rescue Html2rss::Error, RequestService::UnsupportedResponseContentType, JSON::ParserError => error
|
|
126
|
-
Log.warn(
|
|
127
|
+
Log.warn({
|
|
128
|
+
event_name: 'meta_oembed.fetch_failed',
|
|
129
|
+
host: url.host,
|
|
130
|
+
error_class: error.class.name,
|
|
131
|
+
message: "#{self.class}: failed to fetch oEmbed payload (#{error.class}: #{error.message})"
|
|
132
|
+
}.to_json)
|
|
127
133
|
{}
|
|
128
134
|
end
|
|
129
135
|
|
|
@@ -57,16 +57,25 @@ module Html2rss
|
|
|
57
57
|
##
|
|
58
58
|
# @yieldparam article [Hash{Symbol => Object}]
|
|
59
59
|
# @return [Enumerator, void]
|
|
60
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- structured empty/items logs
|
|
60
61
|
def each(&)
|
|
61
62
|
return enum_for(:each) unless block_given?
|
|
62
63
|
|
|
63
64
|
articles = fetch_articles
|
|
64
65
|
if articles.empty?
|
|
65
|
-
Log.info(
|
|
66
|
+
Log.info({
|
|
67
|
+
event_name: 'auto_source.native_feed.empty',
|
|
68
|
+
host: url.host,
|
|
69
|
+
message: "#{self.class}: host=#{url.host} item_count=0 fallback=true"
|
|
70
|
+
}.to_json)
|
|
66
71
|
return
|
|
67
72
|
end
|
|
68
73
|
|
|
69
|
-
Log.info(
|
|
74
|
+
Log.info({
|
|
75
|
+
event_name: 'auto_source.native_feed.items',
|
|
76
|
+
host: url.host,
|
|
77
|
+
message: "#{self.class}: host=#{url.host} item_count=#{articles.size} fallback=false"
|
|
78
|
+
}.to_json)
|
|
70
79
|
articles.each(&)
|
|
71
80
|
end
|
|
72
81
|
|
|
@@ -74,7 +83,8 @@ module Html2rss
|
|
|
74
83
|
|
|
75
84
|
attr_reader :parsed_body, :url, :request_session
|
|
76
85
|
|
|
77
|
-
|
|
86
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- discovery + parse + structured warn
|
|
87
|
+
def fetch_articles
|
|
78
88
|
return [] unless request_session
|
|
79
89
|
|
|
80
90
|
response = Syndication::Discovery.best_feed_response(
|
|
@@ -87,7 +97,12 @@ module Html2rss
|
|
|
87
97
|
|
|
88
98
|
Syndication::Parser.parse_response(response)
|
|
89
99
|
rescue Html2rss::Error, ArgumentError => error
|
|
90
|
-
Log.warn(
|
|
100
|
+
Log.warn({
|
|
101
|
+
event_name: 'auto_source.native_feed.failed',
|
|
102
|
+
host: url.host,
|
|
103
|
+
error_class: error.class.name,
|
|
104
|
+
message: "#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})"
|
|
105
|
+
}.to_json)
|
|
91
106
|
[]
|
|
92
107
|
end
|
|
93
108
|
end
|
|
@@ -12,7 +12,7 @@ module Html2rss
|
|
|
12
12
|
#
|
|
13
13
|
# @see https://schema.org/docs/full.html
|
|
14
14
|
# @see https://developers.google.com/search/docs/appearance/structured-data/article#microdata
|
|
15
|
-
class Schema
|
|
15
|
+
class Schema # rubocop:disable Metrics/ClassLength -- scraper + structured warn paths
|
|
16
16
|
include Enumerable
|
|
17
17
|
|
|
18
18
|
# Matches a leading schema.org URL prefix on @type values (http or https).
|
|
@@ -156,7 +156,11 @@ module Html2rss
|
|
|
156
156
|
def parse_script_tag(script_tag)
|
|
157
157
|
JSON.parse(script_tag.text, symbolize_names: true)
|
|
158
158
|
rescue JSON::ParserError => error
|
|
159
|
-
Log.warn(
|
|
159
|
+
Log.warn({
|
|
160
|
+
event_name: 'schema.parse_failed',
|
|
161
|
+
error_class: error.class.name,
|
|
162
|
+
message: "#{name}: failed to parse JSON (#{error.message})"
|
|
163
|
+
}.to_json)
|
|
160
164
|
[]
|
|
161
165
|
end
|
|
162
166
|
end
|
|
@@ -7,7 +7,7 @@ module Html2rss
|
|
|
7
7
|
module Scraper
|
|
8
8
|
##
|
|
9
9
|
# Scrapes articles from XML sitemaps explicitly linked in HTML or discovered via standard sitemap paths.
|
|
10
|
-
class Sitemap
|
|
10
|
+
class Sitemap # rubocop:disable Metrics/ClassLength -- scraper + structured warn paths
|
|
11
11
|
include Enumerable
|
|
12
12
|
|
|
13
13
|
# Selector for sitemap link tags in HTML head
|
|
@@ -78,6 +78,7 @@ module Html2rss
|
|
|
78
78
|
|
|
79
79
|
attr_reader :parsed_body, :body, :url, :request_session, :min_priority, :max_age_days
|
|
80
80
|
|
|
81
|
+
# rubocop:disable-next Metrics/MethodLength -- structured budget-exhausted warn
|
|
81
82
|
def fetch_and_parse_entries
|
|
82
83
|
entries = parse_direct_sitemap
|
|
83
84
|
return entries unless entries.empty?
|
|
@@ -87,7 +88,11 @@ module Html2rss
|
|
|
87
88
|
|
|
88
89
|
parse_sitemap_xml(xml_body)
|
|
89
90
|
rescue Html2rss::RequestService::RequestBudgetExceeded
|
|
90
|
-
Log.warn(
|
|
91
|
+
Log.warn({
|
|
92
|
+
event_name: 'sitemap.budget_exhausted',
|
|
93
|
+
host: url.host,
|
|
94
|
+
message: "#{self.class}: sitemap fetch stopped — request budget exhausted"
|
|
95
|
+
}.to_json)
|
|
91
96
|
[]
|
|
92
97
|
end
|
|
93
98
|
|
|
@@ -122,10 +127,15 @@ module Html2rss
|
|
|
122
127
|
|
|
123
128
|
Scraper::Sitemap::Parser.call(xml, min_priority:, max_age_days:).entries
|
|
124
129
|
rescue Html2rss::RequestService::RequestBudgetExceeded
|
|
125
|
-
Log.warn(
|
|
130
|
+
Log.warn({
|
|
131
|
+
event_name: 'sitemap.fanout_budget_exhausted',
|
|
132
|
+
host: url.host,
|
|
133
|
+
message: "#{self.class}: sitemap fan-out stopped — request budget exhausted"
|
|
134
|
+
}.to_json)
|
|
126
135
|
nil
|
|
127
136
|
end
|
|
128
137
|
|
|
138
|
+
# rubocop:disable-next Metrics/MethodLength -- structured fetch-failed warn
|
|
129
139
|
def fetch_xml(target_url)
|
|
130
140
|
return unless request_session && target_url
|
|
131
141
|
|
|
@@ -134,7 +144,12 @@ module Html2rss
|
|
|
134
144
|
rescue Html2rss::RequestService::RequestBudgetExceeded
|
|
135
145
|
raise
|
|
136
146
|
rescue Html2rss::Error => error
|
|
137
|
-
Log.warn(
|
|
147
|
+
Log.warn({
|
|
148
|
+
event_name: 'sitemap.fetch_failed',
|
|
149
|
+
host: url.host,
|
|
150
|
+
error_class: error.class.name,
|
|
151
|
+
message: "#{self.class}: failed to fetch sitemap (#{error.class}: #{error.message})"
|
|
152
|
+
}.to_json)
|
|
138
153
|
nil
|
|
139
154
|
end
|
|
140
155
|
|
|
@@ -59,7 +59,7 @@ module Html2rss
|
|
|
59
59
|
nil
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
def fetchable_page_scope?
|
|
62
|
+
def fetchable_page_scope? # rubocop:disable Metrics/MethodLength -- scope checks + structured warn
|
|
63
63
|
return true if page_scope.fetchable?
|
|
64
64
|
|
|
65
65
|
if page_scope.reason == :non_archive
|
|
@@ -70,7 +70,11 @@ module Html2rss
|
|
|
70
70
|
return false
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
logger.warn(
|
|
73
|
+
logger.warn({
|
|
74
|
+
event_name: 'wordpress_api.unsafe_archive_scope',
|
|
75
|
+
host: page_url.host,
|
|
76
|
+
message: "#{WordpressApi}: unable to derive safe WordPress archive scope for #{page_url}"
|
|
77
|
+
}.to_json)
|
|
74
78
|
false
|
|
75
79
|
end
|
|
76
80
|
|
|
@@ -75,19 +75,31 @@ module Html2rss
|
|
|
75
75
|
|
|
76
76
|
attr_reader :parsed_body, :url, :request_session, :page_scope
|
|
77
77
|
|
|
78
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- structured rescue logs
|
|
78
79
|
def fetch_posts
|
|
79
80
|
response = posts_response
|
|
80
81
|
return unless response
|
|
81
82
|
|
|
82
83
|
Array(response.parsed_body)
|
|
83
84
|
rescue RequestService::UnsupportedResponseContentType => error
|
|
84
|
-
Log.warn(
|
|
85
|
+
Log.warn({
|
|
86
|
+
event_name: 'wordpress_api.unsupported_content_type',
|
|
87
|
+
host: url.host,
|
|
88
|
+
error_class: error.class.name,
|
|
89
|
+
message: "#{self.class}: unsupported WordPress API posts content type (#{error.message})"
|
|
90
|
+
}.to_json)
|
|
85
91
|
nil
|
|
86
92
|
rescue JSON::ParserError => error
|
|
87
|
-
Log.warn(
|
|
93
|
+
Log.warn({
|
|
94
|
+
event_name: 'wordpress_api.parse_failed',
|
|
95
|
+
host: url.host,
|
|
96
|
+
error_class: error.class.name,
|
|
97
|
+
message: "#{self.class}: failed to parse WordPress API posts JSON (#{error.message})"
|
|
98
|
+
}.to_json)
|
|
88
99
|
nil
|
|
89
100
|
end
|
|
90
101
|
|
|
102
|
+
# rubocop:disable-next Metrics/MethodLength -- structured fetch-failed warn
|
|
91
103
|
def posts_response
|
|
92
104
|
return unless request_session
|
|
93
105
|
return unless (resolved_posts_url = posts_endpoint_url)
|
|
@@ -98,7 +110,12 @@ module Html2rss
|
|
|
98
110
|
origin_url: url
|
|
99
111
|
)
|
|
100
112
|
rescue Html2rss::Error => error
|
|
101
|
-
Log.warn(
|
|
113
|
+
Log.warn({
|
|
114
|
+
event_name: 'wordpress_api.fetch_failed',
|
|
115
|
+
host: url.host,
|
|
116
|
+
error_class: error.class.name,
|
|
117
|
+
message: "#{self.class}: failed to fetch WordPress API posts (#{error.class}: #{error.message})"
|
|
118
|
+
}.to_json)
|
|
102
119
|
nil
|
|
103
120
|
end
|
|
104
121
|
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -277,7 +277,12 @@ module Html2rss
|
|
|
277
277
|
def run_scraper(instance)
|
|
278
278
|
instance.map { |item| article_from_scraper_item(item, instance) }
|
|
279
279
|
rescue *OPERATIONAL_ERRORS => error
|
|
280
|
-
Log.warn(
|
|
280
|
+
Log.warn({
|
|
281
|
+
event_name: 'auto_source.quarantined',
|
|
282
|
+
host: url.host,
|
|
283
|
+
error_class: error.class.name,
|
|
284
|
+
message: "#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}"
|
|
285
|
+
}.to_json)
|
|
281
286
|
[]
|
|
282
287
|
end
|
|
283
288
|
|
|
@@ -35,6 +35,10 @@ These abort immediately (`AutoFallback::NON_FALLBACK_ERRORS`): unknown strategy,
|
|
|
35
35
|
|
|
36
36
|
Retries share the feed's request/session policy. Pin a concrete strategy when you need a single-hop budget profile.
|
|
37
37
|
|
|
38
|
+
### Terminal fallback outcome
|
|
39
|
+
|
|
40
|
+
A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut` or `BlockedSurfaceDetected`). `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`. `html2rss-web` `ErrorClassifier` maps the raised error to HTTP/cache policy; it does not invent a second cache rule for empty extraction.
|
|
41
|
+
|
|
38
42
|
## Success signals
|
|
39
43
|
|
|
40
44
|
- `Html2rss::RequestService::Response` includes transport metadata for the strategy that produced it.
|
|
@@ -27,16 +27,26 @@ module Html2rss
|
|
|
27
27
|
RequestService::RedirectLimitReached
|
|
28
28
|
].freeze
|
|
29
29
|
|
|
30
|
+
# Transport failures preserved across a later hop that extracts zero items.
|
|
31
|
+
# Re-raised from finalize_failure so web maps them to uncacheable gateway/blocked
|
|
32
|
+
# decisions instead of EXTRACTION_EMPTY.
|
|
33
|
+
TERMINAL_TRANSPORT_ERRORS = [
|
|
34
|
+
RequestService::RequestTimedOut,
|
|
35
|
+
RequestService::BlockedSurfaceDetected
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
30
38
|
##
|
|
31
39
|
# Mutable run state for one auto-fallback chain: attempts plus selected result.
|
|
32
40
|
class AttemptState
|
|
33
|
-
attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution
|
|
41
|
+
attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution,
|
|
42
|
+
:terminal_transport_error
|
|
34
43
|
|
|
35
44
|
def initialize
|
|
36
45
|
@attempts = []
|
|
37
46
|
@result = nil
|
|
38
47
|
@last_response = nil
|
|
39
48
|
@last_error = nil
|
|
49
|
+
@terminal_transport_error = nil
|
|
40
50
|
@entry_resolution = nil
|
|
41
51
|
@resolution_tried = false
|
|
42
52
|
@aborted = false
|
|
@@ -76,6 +86,9 @@ module Html2rss
|
|
|
76
86
|
attempt[:transport_meta] = meta if meta&.any?
|
|
77
87
|
@attempts << attempt
|
|
78
88
|
@last_error = error
|
|
89
|
+
return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
90
|
+
|
|
91
|
+
@terminal_transport_error = error
|
|
79
92
|
end
|
|
80
93
|
|
|
81
94
|
# @param strategy [Symbol] strategy that returned a response
|
|
@@ -85,7 +98,7 @@ module Html2rss
|
|
|
85
98
|
def record_items(strategy:, items_count:, transport_meta: nil)
|
|
86
99
|
@last_error = nil
|
|
87
100
|
attempt = { strategy:, items_count:, error_class: nil }
|
|
88
|
-
attempt[:transport_meta] = transport_meta if transport_meta
|
|
101
|
+
attempt[:transport_meta] = transport_meta if transport_meta&.any?
|
|
89
102
|
@attempts << attempt
|
|
90
103
|
end
|
|
91
104
|
|
|
@@ -183,7 +196,9 @@ module Html2rss
|
|
|
183
196
|
|
|
184
197
|
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
|
|
185
198
|
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
186
|
-
articles, dedup_dropped, admission_drops =
|
|
199
|
+
articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
|
|
200
|
+
config:, response:, request_session:
|
|
201
|
+
)
|
|
187
202
|
state.remember_response(response)
|
|
188
203
|
items_count = articles.size
|
|
189
204
|
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
@@ -204,7 +219,7 @@ module Html2rss
|
|
|
204
219
|
state:)
|
|
205
220
|
end
|
|
206
221
|
|
|
207
|
-
if
|
|
222
|
+
if DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
|
|
208
223
|
state.abort!
|
|
209
224
|
Log.debug("#{self.class}: deterministic HTTP #{response.status}; aborting auto fallback")
|
|
210
225
|
return
|
|
@@ -213,15 +228,7 @@ module Html2rss
|
|
|
213
228
|
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
214
229
|
end
|
|
215
230
|
|
|
216
|
-
|
|
217
|
-
DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def articles_for(response:, request_session:)
|
|
221
|
-
pipeline.deduplicated_articles(config:, response:, request_session:)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# rubocop:disable-next Metrics/ParameterLists -- success kwargs match PipelineOutcome
|
|
231
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- success kwargs + structured selected log
|
|
225
232
|
def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
|
|
226
233
|
attempt_count = state.attempts.size
|
|
227
234
|
state.succeed!(
|
|
@@ -231,63 +238,72 @@ module Html2rss
|
|
|
231
238
|
)
|
|
232
239
|
return unless attempt_count > 1
|
|
233
240
|
|
|
234
|
-
Log.info(
|
|
235
|
-
|
|
236
|
-
|
|
241
|
+
Log.info({
|
|
242
|
+
event_name: 'auto_fallback.selected',
|
|
243
|
+
host: response.url.host,
|
|
244
|
+
strategy: strategy.to_s,
|
|
245
|
+
attempt_count:,
|
|
246
|
+
message: "#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
|
|
247
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
248
|
+
"budget_remaining=#{budget_remaining_label}"
|
|
249
|
+
}.to_json)
|
|
237
250
|
end
|
|
238
251
|
|
|
239
252
|
def finalize_failure(state)
|
|
240
|
-
|
|
241
|
-
|
|
253
|
+
error = failure_error(state)
|
|
254
|
+
if error
|
|
242
255
|
error.attempts = state.attempts if error.is_a?(Html2rss::Error)
|
|
243
256
|
raise error
|
|
244
257
|
end
|
|
245
258
|
|
|
259
|
+
response = state.last_response
|
|
246
260
|
raise NoFeedItemsExtracted.new(
|
|
247
261
|
attempts: state.attempts,
|
|
248
|
-
surface_category: surface_category_for(
|
|
262
|
+
surface_category: (PageRecon.surface_category_for(response:, url: response.url) if response)
|
|
249
263
|
)
|
|
250
264
|
end
|
|
251
265
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
true
|
|
257
|
-
end
|
|
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
|
|
258
270
|
|
|
259
|
-
|
|
260
|
-
return
|
|
271
|
+
error = state.last_error
|
|
272
|
+
return if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
|
|
261
273
|
|
|
262
|
-
|
|
274
|
+
error
|
|
263
275
|
end
|
|
264
276
|
|
|
265
|
-
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
|
277
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- shared payload then info/warn dispatch
|
|
266
278
|
def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
|
|
267
279
|
host = request_session.url.host
|
|
268
280
|
detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
269
281
|
"budget_remaining=#{budget_remaining_label}"
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
"after error=#{error.class} #{detail}"
|
|
282
|
-
}.to_json)
|
|
283
|
-
end
|
|
282
|
+
timed_out = error.is_a?(RequestService::RequestTimedOut)
|
|
283
|
+
payload = {
|
|
284
|
+
event_name: timed_out ? 'auto_fallback.timeout' : 'auto_fallback.error',
|
|
285
|
+
host:,
|
|
286
|
+
strategy: strategy.to_s,
|
|
287
|
+
next_strategy: next_strategy.to_s,
|
|
288
|
+
error_class: error.class.name,
|
|
289
|
+
message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
290
|
+
"after #{timed_out ? "timeout=#{error.class}" : "error=#{error.class}"} #{detail}"
|
|
291
|
+
}.to_json
|
|
292
|
+
timed_out ? Log.info(payload) : Log.warn(payload)
|
|
284
293
|
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
|
|
285
294
|
end
|
|
286
295
|
|
|
287
296
|
def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
297
|
+
host = response.url.host
|
|
298
|
+
Log.info({
|
|
299
|
+
event_name: 'auto_fallback.zero_items',
|
|
300
|
+
host:,
|
|
301
|
+
strategy: strategy.to_s,
|
|
302
|
+
next_strategy: next_strategy.to_s,
|
|
303
|
+
message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
|
|
304
|
+
"host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
305
|
+
"budget_remaining=#{budget_remaining_label}"
|
|
306
|
+
}.to_json)
|
|
291
307
|
end
|
|
292
308
|
|
|
293
309
|
def budget_remaining_label
|
|
@@ -285,10 +285,13 @@ module Html2rss
|
|
|
285
285
|
|
|
286
286
|
def result(applied:, reason:, scrape_url:, probe_count:, winner_score:)
|
|
287
287
|
scrape_host = Html2rss::Url.from_absolute(scrape_url).host
|
|
288
|
-
Log.info(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
288
|
+
Log.info({
|
|
289
|
+
event_name: 'feed_resolution.result',
|
|
290
|
+
host: entry_url.host,
|
|
291
|
+
message: "FeedResolution: entry_host=#{entry_url.host} scrape_host=#{scrape_host} " \
|
|
292
|
+
"applied=#{applied} probe_count=#{probe_count} winner_score=#{winner_score.inspect} " \
|
|
293
|
+
"reason=#{reason}"
|
|
294
|
+
}.to_json)
|
|
292
295
|
Result.new(entry_url: entry_url.to_s, scrape_url:, applied:, reason:, probe_count:, winner_score:)
|
|
293
296
|
end
|
|
294
297
|
end
|
|
@@ -21,9 +21,9 @@ module Html2rss
|
|
|
21
21
|
# combined with ping! on a reused closed selector that never decrements retries,
|
|
22
22
|
# that overflows the stack and kills Falcon workers.
|
|
23
23
|
RETRY_AFTER_SECONDS = 0.25
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# Base-session retries plugin defaults (stack-overflow guard for ping reconnects).
|
|
25
|
+
# Per-request +session.with+ sets +max_retries: 0+; Ruby owns TimeoutError retries
|
|
26
|
+
# against the monotonic deadline so HTTPX cannot stack read timeouts past the cap.
|
|
27
27
|
# Do not reload +:fiber_concurrency+: a reused closed selector made +ping!+
|
|
28
28
|
# skip decrementing retries and recurse until +SystemStackError+.
|
|
29
29
|
#
|
|
@@ -54,19 +54,19 @@ module Html2rss
|
|
|
54
54
|
def perform_execute
|
|
55
55
|
deadline = request_deadline
|
|
56
56
|
reset_redirect_tracking!
|
|
57
|
-
raw_response =
|
|
57
|
+
raw_response = request_with_deadline_retries(deadline:)
|
|
58
58
|
build_response(raw_response)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
private
|
|
62
62
|
|
|
63
|
-
# Caps one
|
|
64
|
-
#
|
|
63
|
+
# Caps one strategy execute (Ruby retries included) so a hanging :default tier
|
|
64
|
+
# cannot consume the full shared auto-fallback budget.
|
|
65
65
|
#
|
|
66
66
|
# @return [Float] monotonic deadline
|
|
67
67
|
def request_deadline
|
|
68
68
|
budget_timeout = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
69
|
-
|
|
69
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + [budget_timeout, attempt_timeout_ceiling].min
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
# @return [Float] connect + read ceiling for a single attempt
|
|
@@ -81,8 +81,30 @@ module Html2rss
|
|
|
81
81
|
@current_url = request_url
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
# Retries HTTPX timeouts in Ruby while the shared monotonic deadline has time left.
|
|
85
|
+
# First try consumes the budget slot; retries do not.
|
|
86
|
+
def request_with_deadline_retries(deadline:)
|
|
87
|
+
state = { retries_remaining: MAX_RETRIES, consume_budget: true }
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
request_with_terminal_redirect_retry(deadline:, consume_budget: state[:consume_budget])
|
|
91
|
+
rescue HTTPX::TimeoutError => error
|
|
92
|
+
prepare_deadline_retry!(deadline:, state:, error:)
|
|
93
|
+
retry
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prepare_deadline_retry!(deadline:, state:, error:)
|
|
98
|
+
raise error if state[:retries_remaining].zero?
|
|
99
|
+
|
|
100
|
+
remaining_timeout_seconds(deadline)
|
|
101
|
+
state[:retries_remaining] -= 1
|
|
102
|
+
state[:consume_budget] = false
|
|
103
|
+
reset_redirect_tracking!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def request_with_terminal_redirect_retry(deadline:, consume_budget: true)
|
|
107
|
+
raw_response = execute_http_request(deadline:, consume_budget:)
|
|
86
108
|
return raw_response unless redirect_limit_reached?(raw_response)
|
|
87
109
|
|
|
88
110
|
unless terminal_redirect_retryable?(raw_response)
|
|
@@ -164,6 +186,7 @@ module Html2rss
|
|
|
164
186
|
session.with(session_options(deadline))
|
|
165
187
|
end
|
|
166
188
|
|
|
189
|
+
# Plugin retries stay off per request; Ruby owns deadline-bounded TimeoutError retries.
|
|
167
190
|
def session_options(deadline)
|
|
168
191
|
remaining = remaining_timeout_seconds(deadline)
|
|
169
192
|
{
|
|
@@ -173,7 +196,7 @@ module Html2rss
|
|
|
173
196
|
max_response_body_size: ctx.policy.max_response_bytes,
|
|
174
197
|
resolver_class: :system,
|
|
175
198
|
redirect_on: redirect_callback,
|
|
176
|
-
**RETRY_OPTIONS
|
|
199
|
+
**RETRY_OPTIONS.merge(max_retries: 0)
|
|
177
200
|
}
|
|
178
201
|
end
|
|
179
202
|
|
|
@@ -219,16 +242,12 @@ module Html2rss
|
|
|
219
242
|
end
|
|
220
243
|
|
|
221
244
|
def remaining_timeout_seconds(deadline)
|
|
222
|
-
remaining = deadline -
|
|
245
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
223
246
|
raise RequestTimedOut, 'Request timed out' if remaining <= 0
|
|
224
247
|
|
|
225
248
|
remaining
|
|
226
249
|
end
|
|
227
250
|
|
|
228
|
-
def monotonic_now
|
|
229
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
251
|
def timeout_error?(error)
|
|
233
252
|
error.is_a?(HTTPX::TimeoutError) || super
|
|
234
253
|
end
|
|
@@ -111,18 +111,24 @@ module Html2rss
|
|
|
111
111
|
|
|
112
112
|
# @param reason [String] timeout classification (budget_exhausted / transport)
|
|
113
113
|
# @return [void]
|
|
114
|
-
# rubocop:disable-next Metrics/AbcSize -- structured timeout fields stay in one log line
|
|
114
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- structured timeout fields stay in one log line
|
|
115
115
|
def log_timeout!(reason:)
|
|
116
116
|
remaining = ctx.budget.remaining_timeout_seconds
|
|
117
117
|
remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
|
|
118
|
+
host = ctx.url.host
|
|
118
119
|
detail = [
|
|
119
120
|
"strategy=#{self.class.name}",
|
|
120
|
-
"host=#{
|
|
121
|
+
"host=#{host}",
|
|
121
122
|
"elapsed=#{format('%.3f', ctx.budget.elapsed_seconds)}s",
|
|
122
123
|
"budget_remaining=#{remaining_label}",
|
|
123
124
|
"reason=#{reason}"
|
|
124
125
|
]
|
|
125
|
-
Log.info(
|
|
126
|
+
Log.info({
|
|
127
|
+
event_name: 'request_service.timeout',
|
|
128
|
+
host:,
|
|
129
|
+
strategy: self.class.name,
|
|
130
|
+
message: "#{self.class}: request timeout #{detail.join(' ')}"
|
|
131
|
+
}.to_json)
|
|
126
132
|
end
|
|
127
133
|
|
|
128
134
|
# @param error [StandardError]
|
|
@@ -119,17 +119,29 @@ module Html2rss
|
|
|
119
119
|
|
|
120
120
|
body = parsed.at_css('body')
|
|
121
121
|
if body
|
|
122
|
-
Html2rss::Log.warn(
|
|
122
|
+
Html2rss::Log.warn({
|
|
123
|
+
event_name: 'sst.normalizer.root_fallback',
|
|
124
|
+
reason: 'body',
|
|
125
|
+
message: 'sst.normalizer root fallback: body'
|
|
126
|
+
}.to_json)
|
|
123
127
|
return body
|
|
124
128
|
end
|
|
125
129
|
|
|
126
130
|
first = parsed.element_children.find { |child| element_root?(child) }
|
|
127
131
|
if first
|
|
128
|
-
Html2rss::Log.warn(
|
|
132
|
+
Html2rss::Log.warn({
|
|
133
|
+
event_name: 'sst.normalizer.root_fallback',
|
|
134
|
+
reason: 'first_element_child',
|
|
135
|
+
message: 'sst.normalizer root fallback: first element child'
|
|
136
|
+
}.to_json)
|
|
129
137
|
return first
|
|
130
138
|
end
|
|
131
139
|
|
|
132
|
-
Html2rss::Log.warn(
|
|
140
|
+
Html2rss::Log.warn({
|
|
141
|
+
event_name: 'sst.normalizer.root_fallback',
|
|
142
|
+
reason: 'self',
|
|
143
|
+
message: 'sst.normalizer root fallback: self'
|
|
144
|
+
}.to_json)
|
|
133
145
|
parsed
|
|
134
146
|
end
|
|
135
147
|
|
|
@@ -146,7 +158,11 @@ module Html2rss
|
|
|
146
158
|
|
|
147
159
|
if @node_count >= MAX_NODES && !@degraded
|
|
148
160
|
@degraded = true
|
|
149
|
-
Html2rss::Log.warn(
|
|
161
|
+
Html2rss::Log.warn({
|
|
162
|
+
event_name: 'sst.normalizer.max_nodes',
|
|
163
|
+
max_nodes: MAX_NODES,
|
|
164
|
+
message: "sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only"
|
|
165
|
+
}.to_json)
|
|
150
166
|
end
|
|
151
167
|
|
|
152
168
|
return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
|
|
@@ -60,7 +60,11 @@ module Html2rss
|
|
|
60
60
|
).tap do |response|
|
|
61
61
|
next unless response
|
|
62
62
|
|
|
63
|
-
Log.info(
|
|
63
|
+
Log.info({
|
|
64
|
+
event_name: 'syndication.discovery.selected',
|
|
65
|
+
host: page.host,
|
|
66
|
+
message: "Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}"
|
|
67
|
+
}.to_json)
|
|
64
68
|
end
|
|
65
69
|
end
|
|
66
70
|
|
|
@@ -6,7 +6,7 @@ module Html2rss
|
|
|
6
6
|
module Syndication
|
|
7
7
|
##
|
|
8
8
|
# Parses RSS 2.0 and Atom documents into AutoSource article hashes.
|
|
9
|
-
module Parser
|
|
9
|
+
module Parser # rubocop:disable Metrics/ModuleLength -- feed formats + structured parse warn
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
##
|
|
@@ -29,7 +29,11 @@ module Html2rss
|
|
|
29
29
|
|
|
30
30
|
items_from(feed, base_url:).filter_map { |item| article_hash(item, base_url:) }
|
|
31
31
|
rescue RSS::Error => error
|
|
32
|
-
Log.warn(
|
|
32
|
+
Log.warn({
|
|
33
|
+
event_name: 'syndication.parse_failed',
|
|
34
|
+
error_class: error.class.name,
|
|
35
|
+
message: "Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})"
|
|
36
|
+
}.to_json)
|
|
33
37
|
[]
|
|
34
38
|
end
|
|
35
39
|
|
data/lib/html2rss/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: html2rss
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.32.
|
|
4
|
+
version: 0.32.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gil Desmarais
|
|
@@ -478,7 +478,7 @@ licenses:
|
|
|
478
478
|
- MIT
|
|
479
479
|
metadata:
|
|
480
480
|
allowed_push_host: https://rubygems.org
|
|
481
|
-
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.
|
|
481
|
+
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.3
|
|
482
482
|
rubygems_mfa_required: 'true'
|
|
483
483
|
rdoc_options: []
|
|
484
484
|
require_paths:
|