html2rss 0.23.0 → 0.24.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 +2 -2
- data/lib/html2rss/article.rb +42 -35
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/channel.rb +159 -79
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
- data/lib/html2rss/feed_builder/json_feed.rb +22 -2
- data/lib/html2rss/feed_builder/rss.rb +19 -19
- data/lib/html2rss/feed_builder.rb +9 -17
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
- data/lib/html2rss/feed_pipeline.rb +63 -56
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
- data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
- data/lib/html2rss/request_service/budget.rb +7 -2
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/strategy.rb +22 -0
- data/lib/html2rss/request_session.rb +4 -0
- data/lib/html2rss/selectors.rb +26 -4
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +15 -3
- metadata +5 -3
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
|
@@ -5,9 +5,10 @@ module Html2rss
|
|
|
5
5
|
# Retries feed extraction across concrete request strategies for the :auto plan.
|
|
6
6
|
#
|
|
7
7
|
# Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
|
|
8
|
+
# Hosted by the pipeline instance: session + extract call back into FeedPipeline.
|
|
8
9
|
class AutoFallback
|
|
9
10
|
# Ordered list of concrete request strategies attempted by the :auto plan.
|
|
10
|
-
CHAIN = %i[faraday botasaurus
|
|
11
|
+
CHAIN = %i[faraday botasaurus].freeze
|
|
11
12
|
|
|
12
13
|
# Error classes that should abort auto fallback immediately.
|
|
13
14
|
NON_FALLBACK_ERRORS = [
|
|
@@ -44,34 +45,49 @@ module Html2rss
|
|
|
44
45
|
|
|
45
46
|
# @param strategy [Symbol] strategy that returned a response
|
|
46
47
|
# @param items_count [Integer] extracted article count
|
|
48
|
+
# @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
|
|
47
49
|
# @return [void]
|
|
48
|
-
def record_items(strategy:, items_count:)
|
|
49
|
-
|
|
50
|
+
def record_items(strategy:, items_count:, transport_meta: nil)
|
|
51
|
+
attempt = { strategy:, items_count:, error_class: nil }
|
|
52
|
+
attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
|
|
53
|
+
@attempts << attempt
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
# @param response [RequestService::Response] successful response
|
|
53
57
|
# @param articles [Array] extracted articles
|
|
58
|
+
# @param dedup_dropped [Integer] articles removed by deduplication
|
|
59
|
+
# @param selected_strategy [Symbol] concrete strategy that produced items
|
|
60
|
+
# @param attempt_count [Integer] number of attempts recorded for this chain
|
|
54
61
|
# @return [void]
|
|
55
|
-
def succeed!(response:, articles:)
|
|
56
|
-
@result =
|
|
62
|
+
def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:)
|
|
63
|
+
@result = PipelineOutcome.new(
|
|
64
|
+
response:,
|
|
65
|
+
articles:,
|
|
66
|
+
dedup_dropped:,
|
|
67
|
+
selected_strategy:,
|
|
68
|
+
attempt_count:,
|
|
69
|
+
strategy_attempts: attempts
|
|
70
|
+
)
|
|
57
71
|
end
|
|
58
72
|
end
|
|
59
73
|
|
|
60
74
|
##
|
|
61
75
|
# @param strategies [Array<Symbol>] ordered concrete strategies for fallback
|
|
62
76
|
# @param budget [RequestService::Budget] shared request budget across retries
|
|
63
|
-
# @param
|
|
64
|
-
# @param
|
|
77
|
+
# @param pipeline [Html2rss::FeedPipeline] host for session + article extraction
|
|
78
|
+
# @param config [Html2rss::Config] validated feed config
|
|
79
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources] budget + policy
|
|
65
80
|
# @return [void]
|
|
66
|
-
def initialize(strategies:, budget:,
|
|
81
|
+
def initialize(strategies:, budget:, pipeline:, config:, resources:)
|
|
67
82
|
@strategies = strategies
|
|
68
83
|
@budget = budget
|
|
69
|
-
@
|
|
70
|
-
@
|
|
84
|
+
@pipeline = pipeline
|
|
85
|
+
@config = config
|
|
86
|
+
@resources = resources
|
|
71
87
|
end
|
|
72
88
|
|
|
73
89
|
##
|
|
74
|
-
# @return [
|
|
90
|
+
# @return [Html2rss::FeedPipeline::PipelineOutcome] scrape-finished state for materialization
|
|
75
91
|
def call
|
|
76
92
|
state = run_attempts
|
|
77
93
|
return state.result if state.succeeded?
|
|
@@ -81,7 +97,7 @@ module Html2rss
|
|
|
81
97
|
|
|
82
98
|
private
|
|
83
99
|
|
|
84
|
-
attr_reader :strategies, :budget, :
|
|
100
|
+
attr_reader :strategies, :budget, :pipeline, :config, :resources
|
|
85
101
|
|
|
86
102
|
def run_attempts
|
|
87
103
|
AttemptState.new.tap do |state|
|
|
@@ -93,7 +109,7 @@ module Html2rss
|
|
|
93
109
|
end
|
|
94
110
|
|
|
95
111
|
def attempt(strategy:, next_strategy:, state:)
|
|
96
|
-
request_session =
|
|
112
|
+
request_session = pipeline.request_session_for(config, strategy:, resources:)
|
|
97
113
|
response = fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
98
114
|
return unless response
|
|
99
115
|
|
|
@@ -106,38 +122,65 @@ module Html2rss
|
|
|
106
122
|
raise
|
|
107
123
|
rescue StandardError => error
|
|
108
124
|
state.record_error(strategy:, error:)
|
|
109
|
-
|
|
110
|
-
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message}")
|
|
125
|
+
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
|
|
111
126
|
nil
|
|
112
127
|
end
|
|
113
128
|
|
|
114
129
|
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
115
|
-
articles = articles_for
|
|
130
|
+
articles, dedup_dropped = articles_for(response:, request_session:)
|
|
116
131
|
items_count = articles.size
|
|
117
|
-
state.record_items(strategy:, items_count:)
|
|
118
|
-
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count}"
|
|
119
|
-
|
|
132
|
+
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
133
|
+
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
134
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
135
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
136
|
+
return record_success(response:, strategy:, articles:, dedup_dropped:, state:) if items_count.positive?
|
|
120
137
|
|
|
121
|
-
log_info_fallback_zero_items(strategy:, next_strategy:) if next_strategy
|
|
138
|
+
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
122
139
|
end
|
|
123
140
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
def articles_for(response:, request_session:)
|
|
142
|
+
pipeline.deduplicated_articles(config:, response:, request_session:)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def record_success(response:, strategy:, articles:, dedup_dropped:, state:)
|
|
146
|
+
attempt_count = state.attempts.size
|
|
147
|
+
state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy, attempt_count:)
|
|
148
|
+
return unless attempt_count > 1
|
|
127
149
|
|
|
128
|
-
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{
|
|
150
|
+
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
|
|
151
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
152
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
129
153
|
end
|
|
130
154
|
|
|
131
155
|
def finalize_failure(attempts:)
|
|
132
156
|
raise NoFeedItemsExtracted.new(attempts:)
|
|
133
157
|
end
|
|
134
158
|
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
160
|
+
def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
|
|
161
|
+
host = request_session.url.host
|
|
162
|
+
detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
163
|
+
"budget_remaining=#{budget_remaining_label}"
|
|
164
|
+
if error.is_a?(RequestService::RequestTimedOut)
|
|
165
|
+
Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
166
|
+
"after timeout=#{error.class} #{detail}")
|
|
167
|
+
else
|
|
168
|
+
Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
169
|
+
"after error=#{error.class} #{detail}")
|
|
170
|
+
end
|
|
171
|
+
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
|
|
172
|
+
end
|
|
173
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
174
|
+
|
|
175
|
+
def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
|
|
176
|
+
Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
|
|
177
|
+
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
178
|
+
"budget_remaining=#{budget_remaining_label}")
|
|
137
179
|
end
|
|
138
180
|
|
|
139
|
-
def
|
|
140
|
-
|
|
181
|
+
def budget_remaining_label
|
|
182
|
+
remaining = budget.remaining_timeout_seconds
|
|
183
|
+
remaining.nil? ? 'untracked' : format('%.3f', remaining)
|
|
141
184
|
end
|
|
142
185
|
end
|
|
143
186
|
end
|
|
@@ -4,8 +4,13 @@ module Html2rss
|
|
|
4
4
|
##
|
|
5
5
|
# Builds feeds from validated config through request, extraction, and rendering stages.
|
|
6
6
|
class FeedPipeline
|
|
7
|
-
#
|
|
8
|
-
|
|
7
|
+
# Scrape-finished facts after request + extraction + dedup (before Channel/Status materialize).
|
|
8
|
+
# selected_strategy: set on :auto success; nil otherwise.
|
|
9
|
+
# attempt_count: auto attempts attempted; 0 outside :auto.
|
|
10
|
+
# strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
|
|
11
|
+
PipelineOutcome = Data.define(
|
|
12
|
+
:response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
##
|
|
11
16
|
# @param raw_config [Hash{Symbol => Object}] user-provided feed config
|
|
@@ -14,34 +19,55 @@ module Html2rss
|
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
##
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
# Runs the pipeline once and returns an opaque, Marshal-cacheable result.
|
|
23
|
+
#
|
|
24
|
+
# @return [Html2rss::FeedResult]
|
|
25
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- Status kwargs stay co-located with Channel
|
|
26
|
+
def to_result
|
|
27
|
+
config = Config.from_hash(raw_config, params: raw_config[:params])
|
|
28
|
+
outcome = pipeline_outcome_for(config)
|
|
29
|
+
channel = Channel.from_response(outcome.response, overrides: config.channel)
|
|
30
|
+
status = Status.build(
|
|
31
|
+
articles: outcome.articles,
|
|
32
|
+
dedup_dropped: outcome.dedup_dropped,
|
|
33
|
+
selected_strategy: outcome.selected_strategy,
|
|
34
|
+
attempt_count: outcome.attempt_count,
|
|
35
|
+
strategy_attempts: outcome.strategy_attempts
|
|
36
|
+
)
|
|
37
|
+
FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
|
|
23
38
|
end
|
|
39
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
24
40
|
|
|
25
|
-
|
|
26
|
-
# @
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
# @api private Host seam for {AutoFallback} (and single-strategy path).
|
|
42
|
+
# @param config [Html2rss::Config]
|
|
43
|
+
# @param strategy [Symbol]
|
|
44
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
|
|
45
|
+
# @return [Html2rss::RequestSession]
|
|
46
|
+
def request_session_for(config, strategy:, resources:)
|
|
47
|
+
RequestSession.build(
|
|
48
|
+
config:,
|
|
49
|
+
strategy:,
|
|
50
|
+
budget: resources.budget,
|
|
51
|
+
policy: resources.policy
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private Host seam for {AutoFallback} (and single-strategy path).
|
|
56
|
+
# @param config [Html2rss::Config]
|
|
57
|
+
# @param response [Html2rss::RequestService::Response]
|
|
58
|
+
# @param request_session [Html2rss::RequestSession]
|
|
59
|
+
# @return [Array(Array<Html2rss::Article>, Integer)] unique articles and drop count
|
|
60
|
+
def deduplicated_articles(config:, response:, request_session:)
|
|
61
|
+
collected = collect_articles(config:, response:, request_session:)
|
|
62
|
+
unique = Article::Deduplicator.new(collected).call
|
|
63
|
+
[unique, collected.size - unique.size]
|
|
32
64
|
end
|
|
33
65
|
|
|
34
66
|
private
|
|
35
67
|
|
|
36
68
|
attr_reader :raw_config
|
|
37
69
|
|
|
38
|
-
def
|
|
39
|
-
config = Config.from_hash(raw_config, params: raw_config[:params])
|
|
40
|
-
state = pipeline_state_for(config)
|
|
41
|
-
yield response: state.fetch(:response), config:, articles: state.fetch(:articles)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def pipeline_state_for(config)
|
|
70
|
+
def pipeline_outcome_for(config)
|
|
45
71
|
plan = StrategyPlan.resolve(config.strategy)
|
|
46
72
|
resources = RuntimePolicy.resources_for(config)
|
|
47
73
|
if plan.is_a?(StrategyPlan::Auto)
|
|
@@ -54,52 +80,33 @@ module Html2rss
|
|
|
54
80
|
def run_pipeline_for_strategy(config, strategy:, resources:)
|
|
55
81
|
request_session = request_session_for(config, strategy:, resources:)
|
|
56
82
|
response = request_session.fetch_initial_response
|
|
57
|
-
articles = deduplicated_articles(
|
|
58
|
-
|
|
83
|
+
articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
|
|
84
|
+
PipelineOutcome.new(
|
|
85
|
+
response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
59
86
|
)
|
|
60
|
-
{ response:, articles: }
|
|
61
87
|
end
|
|
62
88
|
|
|
63
|
-
def request_session_for(config, strategy:, resources:)
|
|
64
|
-
RequestSession.build(
|
|
65
|
-
config:,
|
|
66
|
-
strategy:,
|
|
67
|
-
budget: resources.budget,
|
|
68
|
-
policy: resources.policy
|
|
69
|
-
)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def deduplicated_articles(extraction)
|
|
73
|
-
Article::Deduplicator.new(collect_articles(extraction)).call
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# rubocop:disable Metrics/MethodLength
|
|
77
89
|
def run_auto_pipeline(config, resources:)
|
|
78
90
|
AutoFallback.new(
|
|
79
91
|
strategies: AutoFallback::CHAIN,
|
|
80
92
|
budget: resources.budget,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
end,
|
|
85
|
-
articles_for: lambda do |response:, request_session:|
|
|
86
|
-
deduplicated_articles(ExtractionContext.new(config:, response:, request_session:))
|
|
87
|
-
end
|
|
93
|
+
pipeline: self,
|
|
94
|
+
config:,
|
|
95
|
+
resources:
|
|
88
96
|
).call
|
|
89
97
|
end
|
|
90
|
-
# rubocop:enable Metrics/MethodLength
|
|
91
98
|
|
|
92
|
-
def collect_articles(
|
|
93
|
-
selector_articles(
|
|
99
|
+
def collect_articles(config:, response:, request_session:)
|
|
100
|
+
selector_articles(config:, response:, request_session:) +
|
|
101
|
+
auto_source_articles(config:, response:, request_session:)
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
# rubocop:disable Metrics/MethodLength
|
|
97
|
-
def selector_articles(
|
|
98
|
-
config = extraction.config
|
|
105
|
+
def selector_articles(config:, response:, request_session:)
|
|
99
106
|
return [] unless (selectors = config.selectors)
|
|
100
107
|
|
|
101
|
-
page_responses =
|
|
102
|
-
|
|
108
|
+
page_responses = request_session.page_responses(
|
|
109
|
+
response,
|
|
103
110
|
pagination_config: selectors.dig(:items, :pagination)
|
|
104
111
|
)
|
|
105
112
|
|
|
@@ -114,10 +121,10 @@ module Html2rss
|
|
|
114
121
|
end
|
|
115
122
|
# rubocop:enable Metrics/MethodLength
|
|
116
123
|
|
|
117
|
-
def auto_source_articles(
|
|
118
|
-
return [] unless (auto_source =
|
|
124
|
+
def auto_source_articles(config:, response:, request_session:)
|
|
125
|
+
return [] unless (auto_source = config.auto_source)
|
|
119
126
|
|
|
120
|
-
AutoSource.new(
|
|
127
|
+
AutoSource.new(response, auto_source, request_session:).articles
|
|
121
128
|
end
|
|
122
129
|
end
|
|
123
130
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Closed Marshal-cacheable handle for one scrape.
|
|
6
|
+
#
|
|
7
|
+
# Frozen consumer query/render set (do not grow without an explicit contract change):
|
|
8
|
+
# {#empty?}, {#channel_title}, {#to_rss}, {#to_json_feed}, and {#status}.
|
|
9
|
+
#
|
|
10
|
+
# Non-goals: no Channel reader, no Articles reader, no peephole into scrape internals.
|
|
11
|
+
# html2rss-web and other consumers must use only this surface.
|
|
12
|
+
#
|
|
13
|
+
# @note Gem contract: instances must round-trip through +Marshal.dump+ / +Marshal.load+
|
|
14
|
+
# so web can cache one scrape and render RSS or JSON Feed on read. Loaded results
|
|
15
|
+
# are re-frozen via private {#marshal_load}. For trusted cache reads only,
|
|
16
|
+
# +Marshal.load(payload, freeze: true)+ is also safe.
|
|
17
|
+
class FeedResult
|
|
18
|
+
##
|
|
19
|
+
# @param channel [Html2rss::Channel] channel metadata (internal; not exposed on the public API)
|
|
20
|
+
# @param articles [Array<Html2rss::Article>] extracted articles (deduplicated; not exposed)
|
|
21
|
+
# @param status [Html2rss::Status] pipeline telemetry
|
|
22
|
+
# @param stylesheets [Array<Hash>] optional RSS stylesheet configs
|
|
23
|
+
def initialize(channel:, articles:, status:, stylesheets: [])
|
|
24
|
+
raise ArgumentError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
|
|
25
|
+
raise ArgumentError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
|
|
26
|
+
|
|
27
|
+
articles = Array(articles)
|
|
28
|
+
raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
|
|
29
|
+
|
|
30
|
+
@channel = channel
|
|
31
|
+
@articles = articles.dup.freeze
|
|
32
|
+
@status = status
|
|
33
|
+
@stylesheets = freeze_stylesheets(stylesheets)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @return [Boolean] true when the scrape produced no items
|
|
39
|
+
def empty? = @articles.empty?
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# Channel title string only — does not expose the channel object.
|
|
43
|
+
#
|
|
44
|
+
# @return [String] title from the materialized channel
|
|
45
|
+
def channel_title = @channel.title
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# @return [RSS::Rss] RSS 2.0 document
|
|
49
|
+
def to_rss
|
|
50
|
+
FeedBuilder::Rss.new(
|
|
51
|
+
channel: @channel,
|
|
52
|
+
articles: @articles,
|
|
53
|
+
stylesheets: @stylesheets,
|
|
54
|
+
generator: status.to_generator_comment
|
|
55
|
+
).call
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# @param feed_url [String, nil] optional self URL for JSON Feed (+feed_url+)
|
|
60
|
+
# @return [Hash] JSON Feed 1.1 hash
|
|
61
|
+
def to_json_feed(feed_url: nil)
|
|
62
|
+
FeedBuilder::JsonFeed.new(
|
|
63
|
+
channel: @channel,
|
|
64
|
+
articles: @articles,
|
|
65
|
+
feed_url:,
|
|
66
|
+
user_comment: status.to_generator_comment
|
|
67
|
+
).call
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# @return [Html2rss::Status] frozen scraper tallies, dedup count, and generator formatter.
|
|
72
|
+
# {#to_h} on +status+ is a stable consumer contract for observability payloads.
|
|
73
|
+
attr_reader :status
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Constructor payload for Marshal (avoids default ivar thaw). Rebuilds via {#initialize}.
|
|
78
|
+
def marshal_dump = [@channel, @articles, @status, @stylesheets]
|
|
79
|
+
|
|
80
|
+
def marshal_load((channel, articles, status, stylesheets))
|
|
81
|
+
initialize(channel:, articles:, status:, stylesheets:)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def freeze_stylesheets(stylesheets)
|
|
85
|
+
Array(stylesheets).map do |sheet|
|
|
86
|
+
sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
|
|
87
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
88
|
+
end.freeze
|
|
89
|
+
end.freeze
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -10,7 +10,7 @@ module Html2rss
|
|
|
10
10
|
# Default Botasaurus scrape options when no explicit config is provided.
|
|
11
11
|
DEFAULT_OPTIONS = {
|
|
12
12
|
navigation_mode: 'auto',
|
|
13
|
-
max_retries:
|
|
13
|
+
max_retries: 1,
|
|
14
14
|
headless: false
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
@@ -30,6 +30,17 @@ module Html2rss
|
|
|
30
30
|
lang
|
|
31
31
|
].freeze
|
|
32
32
|
|
|
33
|
+
# Allowlisted upstream response keys exposed as Response#transport_meta.
|
|
34
|
+
META_KEYS = %w[
|
|
35
|
+
request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Remaining seconds at or below which Botasaurus retries are disabled.
|
|
39
|
+
TIGHT_BUDGET_SECONDS = 12
|
|
40
|
+
|
|
41
|
+
# Seconds reserved from remaining budget before setting wait_timeout_seconds.
|
|
42
|
+
BUDGET_WAIT_RESERVE_SECONDS = 2
|
|
43
|
+
|
|
33
44
|
# Parsed Botasaurus response wrapper.
|
|
34
45
|
class ParsedResponse
|
|
35
46
|
# Fallback headers when upstream omits response headers.
|
|
@@ -90,6 +101,9 @@ module Html2rss
|
|
|
90
101
|
# @return [String, nil] final URL reported by upstream
|
|
91
102
|
def final_url = payload['final_url']
|
|
92
103
|
|
|
104
|
+
# @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
|
|
105
|
+
def transport_meta = payload.slice(*META_KEYS).compact.freeze
|
|
106
|
+
|
|
93
107
|
private
|
|
94
108
|
|
|
95
109
|
attr_reader :payload, :transport_status
|
|
@@ -109,6 +123,7 @@ module Html2rss
|
|
|
109
123
|
##
|
|
110
124
|
# @param url [Html2rss::Url] canonical URL to scrape
|
|
111
125
|
# @param options [Hash] validated request.botasaurus options
|
|
126
|
+
# @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
|
|
112
127
|
# @option options [String] :navigation_mode
|
|
113
128
|
# @option options [Integer] :max_retries
|
|
114
129
|
# @option options [String] :wait_for_selector
|
|
@@ -121,14 +136,15 @@ module Html2rss
|
|
|
121
136
|
# @option options [String] :user_agent
|
|
122
137
|
# @option options [Array<Integer>] :window_size
|
|
123
138
|
# @option options [String] :lang
|
|
124
|
-
def initialize(url:, options: {})
|
|
139
|
+
def initialize(url:, options: {}, remaining_timeout_seconds: nil)
|
|
125
140
|
@url = url
|
|
126
141
|
@options = options
|
|
142
|
+
@remaining_timeout_seconds = remaining_timeout_seconds
|
|
127
143
|
end
|
|
128
144
|
|
|
129
|
-
# @return [Hash] payload for POST /scrape
|
|
145
|
+
# @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
|
|
130
146
|
def request_payload
|
|
131
|
-
DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s)
|
|
147
|
+
DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s).then { clamp_for_budget(_1) }
|
|
132
148
|
end
|
|
133
149
|
|
|
134
150
|
# @param transport_response [Faraday::Response] upstream HTTP response
|
|
@@ -145,13 +161,25 @@ module Html2rss
|
|
|
145
161
|
|
|
146
162
|
private
|
|
147
163
|
|
|
148
|
-
attr_reader :url, :options
|
|
164
|
+
attr_reader :url, :options, :remaining_timeout_seconds
|
|
149
165
|
|
|
150
166
|
def filtered_options
|
|
151
167
|
OPTION_KEYS.each_with_object({}) do |key, normalized|
|
|
152
168
|
normalized[key] = options[key] if options.key?(key)
|
|
153
169
|
end
|
|
154
170
|
end
|
|
171
|
+
|
|
172
|
+
def clamp_for_budget(payload)
|
|
173
|
+
remaining = remaining_timeout_seconds
|
|
174
|
+
return payload if remaining.nil?
|
|
175
|
+
|
|
176
|
+
clamped = payload.dup
|
|
177
|
+
clamped[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
|
|
178
|
+
budget_wait = [1, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
|
|
179
|
+
configured = clamped[:wait_timeout_seconds]
|
|
180
|
+
clamped[:wait_timeout_seconds] = configured ? [configured, budget_wait].min : budget_wait
|
|
181
|
+
clamped
|
|
182
|
+
end
|
|
155
183
|
end
|
|
156
184
|
end
|
|
157
185
|
end
|
|
@@ -27,7 +27,8 @@ module Html2rss
|
|
|
27
27
|
body: parsed_response.html,
|
|
28
28
|
headers: parsed_response.headers,
|
|
29
29
|
url: response_url(parsed_response.final_url),
|
|
30
|
-
status: parsed_response.status
|
|
30
|
+
status: parsed_response.status,
|
|
31
|
+
transport_meta: parsed_response.transport_meta
|
|
31
32
|
)
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -52,7 +53,11 @@ module Html2rss
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def contract
|
|
55
|
-
@contract ||= BotasaurusContract.new(
|
|
56
|
+
@contract ||= BotasaurusContract.new(
|
|
57
|
+
url: ctx.url,
|
|
58
|
+
options: ctx.request.fetch(:botasaurus, {}),
|
|
59
|
+
remaining_timeout_seconds: attempt_timeout_seconds
|
|
60
|
+
)
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
def client
|
|
@@ -60,9 +65,13 @@ module Html2rss
|
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
def request_options
|
|
63
|
-
timeout
|
|
68
|
+
{ timeout: attempt_timeout_seconds.to_i }
|
|
69
|
+
end
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
def attempt_timeout_seconds
|
|
72
|
+
@attempt_timeout_seconds ||= ctx.budget.effective_timeout_seconds(
|
|
73
|
+
fallback: ctx.policy.total_timeout_seconds
|
|
74
|
+
)
|
|
66
75
|
end
|
|
67
76
|
|
|
68
77
|
def content_type_header
|
|
@@ -82,11 +82,16 @@ module Html2rss
|
|
|
82
82
|
def remaining_timeout_seconds
|
|
83
83
|
return unless @total_timeout_seconds
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
remaining = @total_timeout_seconds - elapsed
|
|
85
|
+
remaining = @total_timeout_seconds - elapsed_seconds
|
|
87
86
|
[remaining, 0.0].max
|
|
88
87
|
end
|
|
89
88
|
|
|
89
|
+
##
|
|
90
|
+
# @return [Float] seconds since this budget was created
|
|
91
|
+
def elapsed_seconds
|
|
92
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
|
|
93
|
+
end
|
|
94
|
+
|
|
90
95
|
##
|
|
91
96
|
# Resolves wall-clock seconds for the next adapter attempt.
|
|
92
97
|
#
|
|
@@ -7,12 +7,16 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# To be used by strategies to provide their response.
|
|
9
9
|
class Response
|
|
10
|
+
# Default when a strategy does not attach transport telemetry.
|
|
11
|
+
EMPTY_TRANSPORT_META = {}.freeze
|
|
12
|
+
|
|
10
13
|
##
|
|
11
14
|
# @param body [String] the body of the response
|
|
12
15
|
# @param url [Html2rss::Url] the final request URL
|
|
13
16
|
# @param headers [Hash] the headers of the response
|
|
14
17
|
# @param status [Integer, nil] the HTTP status code when available
|
|
15
|
-
|
|
18
|
+
# @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
|
|
19
|
+
def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META)
|
|
16
20
|
@body = body
|
|
17
21
|
|
|
18
22
|
headers = headers.dup
|
|
@@ -22,6 +26,7 @@ module Html2rss
|
|
|
22
26
|
@headers = headers
|
|
23
27
|
@status = status
|
|
24
28
|
@url = url
|
|
29
|
+
@transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
# @return [String] the raw body of the response
|
|
@@ -36,6 +41,9 @@ module Html2rss
|
|
|
36
41
|
# @return [Html2rss::Url] the URL of the response
|
|
37
42
|
attr_reader :url
|
|
38
43
|
|
|
44
|
+
# @return [Hash] allowlisted upstream transport telemetry
|
|
45
|
+
attr_reader :transport_meta
|
|
46
|
+
|
|
39
47
|
# @return [String] normalized content type header value
|
|
40
48
|
def content_type = header('content-type').to_s
|
|
41
49
|
|
|
@@ -86,6 +86,9 @@ module Html2rss
|
|
|
86
86
|
|
|
87
87
|
def check_timeout!
|
|
88
88
|
ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
89
|
+
rescue RequestTimedOut
|
|
90
|
+
log_timeout!(reason: 'budget_exhausted')
|
|
91
|
+
raise
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
# @return [ResponseGuard, nil]
|
|
@@ -96,6 +99,8 @@ module Html2rss
|
|
|
96
99
|
# @raise [StandardError]
|
|
97
100
|
def handle_error(error)
|
|
98
101
|
if timeout_error?(error)
|
|
102
|
+
log_timeout!(reason: 'transport')
|
|
103
|
+
Log.debug("#{self.class}: transport timeout message=#{error.message}")
|
|
99
104
|
raise RequestTimedOut, error.message
|
|
100
105
|
elsif connection_error?(error)
|
|
101
106
|
translate_connection_error(error)
|
|
@@ -104,6 +109,23 @@ module Html2rss
|
|
|
104
109
|
end
|
|
105
110
|
end
|
|
106
111
|
|
|
112
|
+
# @param reason [String] timeout classification (budget_exhausted / transport)
|
|
113
|
+
# @return [void]
|
|
114
|
+
# rubocop:disable Metrics/AbcSize -- structured timeout fields stay in one log line
|
|
115
|
+
def log_timeout!(reason:)
|
|
116
|
+
remaining = ctx.budget.remaining_timeout_seconds
|
|
117
|
+
remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
|
|
118
|
+
detail = [
|
|
119
|
+
"strategy=#{self.class.name}",
|
|
120
|
+
"host=#{ctx.url.host}",
|
|
121
|
+
"elapsed=#{format('%.3f', ctx.budget.elapsed_seconds)}s",
|
|
122
|
+
"budget_remaining=#{remaining_label}",
|
|
123
|
+
"reason=#{reason}"
|
|
124
|
+
]
|
|
125
|
+
Log.info("#{self.class}: request timeout #{detail.join(' ')}")
|
|
126
|
+
end
|
|
127
|
+
# rubocop:enable Metrics/AbcSize
|
|
128
|
+
|
|
107
129
|
# @param error [StandardError]
|
|
108
130
|
# @return [void]
|
|
109
131
|
# @raise [StandardError]
|