html2rss 0.29.1 → 0.31.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 +8 -8
- data/html2rss.gemspec +1 -3
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +1 -1
- data/lib/html2rss/auto_source/cleanup.rb +15 -6
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -14
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +2 -2
- data/lib/html2rss/batch.rb +6 -4
- data/lib/html2rss/capture/README.md +7 -5
- data/lib/html2rss/capture.rb +68 -36
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +1 -1
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +6 -4
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +35 -101
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +19 -9
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/README.md +8 -8
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +1 -1
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +7 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +5 -3
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +15 -25
- data/lib/html2rss/mcp/outcome/playbook.rb +7 -7
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +112 -132
- data/lib/html2rss/mcp/server.rb +27 -19
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +20 -31
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
- data/lib/html2rss/request_service/compressed_body.rb +17 -9
- data/lib/html2rss/request_service/httpx_strategy.rb +239 -0
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/response_guard.rb +0 -16
- data/lib/html2rss/request_service/strategy.rb +22 -11
- data/lib/html2rss/request_service.rb +46 -85
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -172
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +27 -19
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +19 -44
- data/lib/html2rss/request_service/faraday_strategy.rb +0 -233
- data/lib/html2rss/selectors/item_scope.rb +0 -30
|
@@ -6,9 +6,11 @@ module Html2rss
|
|
|
6
6
|
# Diagnostic inspect path (not Capture or Recon ownership). Fetches via {.probe},
|
|
7
7
|
# then adds scraper/XHR diagnostics for curation inspect surfaces.
|
|
8
8
|
module Diagnostics # rubocop:disable Metrics/ModuleLength -- diagnostic wire fields stay co-located
|
|
9
|
+
Report = Data.define(:data)
|
|
10
|
+
|
|
9
11
|
##
|
|
10
12
|
# Typed diagnostic report for inspect wire payloads and Outcome policy.
|
|
11
|
-
Report
|
|
13
|
+
class Report
|
|
12
14
|
##
|
|
13
15
|
# @return [Boolean]
|
|
14
16
|
def alternate_feeds?
|
|
@@ -115,97 +117,85 @@ module Html2rss
|
|
|
115
117
|
end
|
|
116
118
|
end
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
120
|
+
class << self
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def build_data(probe, recon, response)
|
|
124
|
+
data = recon.to_h.merge(
|
|
125
|
+
strategy: probe.strategy,
|
|
126
|
+
scraper_eligibility: scraper_info(safe_parsed_body(response)),
|
|
127
|
+
html_present: html_present?(recon, response),
|
|
128
|
+
likely_js_shell: likely_js_shell?(recon, response),
|
|
129
|
+
redirect_summary: redirect_summary(recon)
|
|
130
|
+
)
|
|
131
|
+
data[:xhr_capture] = xhr_capture_info(response) if probe.strategy == :botasaurus
|
|
132
|
+
log_js_shell(data, response.body&.bytesize.to_i) if data[:likely_js_shell]
|
|
133
|
+
data
|
|
134
|
+
end
|
|
132
135
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
def resolve_inspect_strategy(strategy, deep:)
|
|
137
|
+
name = (strategy || :auto).to_sym
|
|
138
|
+
return :botasaurus if deep && name == :auto && MCP::Runtime.botasaurus_configured?
|
|
136
139
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
module_function :resolve_inspect_strategy
|
|
140
|
-
private_class_method :resolve_inspect_strategy
|
|
140
|
+
name
|
|
141
|
+
end
|
|
141
142
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
module_function :html_present?
|
|
146
|
-
private_class_method :html_present?
|
|
143
|
+
def html_present?(recon, response)
|
|
144
|
+
recon.html_response && !response.body.to_s.empty?
|
|
145
|
+
end
|
|
147
146
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
def likely_js_shell?(recon, response)
|
|
148
|
+
return false unless html_present?(recon, response)
|
|
149
|
+
return false if recon.articles_count.positive?
|
|
150
|
+
return false if recon.blocked_surface || recon.surface_category == :blocked_surface
|
|
152
151
|
|
|
153
|
-
|
|
152
|
+
return true if recon.surface_category == :app_shell
|
|
154
153
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
module_function :likely_js_shell?
|
|
159
|
-
private_class_method :likely_js_shell?
|
|
154
|
+
response.body.bytesize >= JS_SHELL_MIN_BODY_BYTES &&
|
|
155
|
+
SurfaceCategory[recon.surface_category].weak?
|
|
156
|
+
end
|
|
160
157
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
module_function :redirect_summary
|
|
170
|
-
private_class_method :redirect_summary
|
|
158
|
+
def redirect_summary(recon)
|
|
159
|
+
{
|
|
160
|
+
requested_url: recon.requested_url,
|
|
161
|
+
final_url: recon.final_url,
|
|
162
|
+
status: recon.status,
|
|
163
|
+
scheme_downgrade: recon.scheme_downgrade
|
|
164
|
+
}
|
|
165
|
+
end
|
|
171
166
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
module_function :log_js_shell
|
|
178
|
-
private_class_method :log_js_shell
|
|
167
|
+
def log_js_shell(data, body_bytesize)
|
|
168
|
+
Log.debug(
|
|
169
|
+
"Diagnostics js_shell: bytesize=#{body_bytesize} surface_category=#{data[:surface_category]}"
|
|
170
|
+
)
|
|
171
|
+
end
|
|
179
172
|
|
|
180
|
-
|
|
181
|
-
|
|
173
|
+
def safe_parsed_body(response)
|
|
174
|
+
return unless response.html_response?
|
|
182
175
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
)
|
|
176
|
+
response.parsed_body
|
|
177
|
+
rescue RequestService::UnsupportedResponseContentType
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def error_report(url, error) # rubocop:disable Metrics/MethodLength -- error hash mirrors success report shape
|
|
182
|
+
Report.new(
|
|
183
|
+
data: {
|
|
184
|
+
requested_url: url.to_s,
|
|
185
|
+
final_url: url.to_s,
|
|
186
|
+
status: nil,
|
|
187
|
+
scheme_downgrade: false,
|
|
188
|
+
alternate_feeds: [],
|
|
189
|
+
surface_category: :unsupported_surface,
|
|
190
|
+
articles_count: 0,
|
|
191
|
+
html_response: false,
|
|
192
|
+
content_type: nil,
|
|
193
|
+
strategy: nil,
|
|
194
|
+
scraper_eligibility: { error: "#{error.class} - #{error.message}" }
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
end
|
|
206
198
|
end
|
|
207
|
-
module_function :error_report
|
|
208
|
-
private_class_method :error_report
|
|
209
199
|
end
|
|
210
200
|
end
|
|
211
201
|
end
|
data/lib/html2rss/page_recon.rb
CHANGED
|
@@ -8,12 +8,14 @@ module Html2rss
|
|
|
8
8
|
# article count. Diagnostic fetch for Inspect/Recon lives on {.probe} — not twin
|
|
9
9
|
# fetch helpers in those callers.
|
|
10
10
|
class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
|
|
11
|
+
Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response)
|
|
12
|
+
|
|
11
13
|
##
|
|
12
14
|
# Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
|
|
13
|
-
Assessment
|
|
15
|
+
class Assessment
|
|
14
16
|
##
|
|
15
17
|
# @return [Html2rss::SurfaceCategory]
|
|
16
|
-
def category = SurfaceCategory
|
|
18
|
+
def category = SurfaceCategory[surface_category]
|
|
17
19
|
|
|
18
20
|
##
|
|
19
21
|
# @return [Boolean]
|
|
@@ -27,8 +29,6 @@ module Html2rss
|
|
|
27
29
|
# @return [Boolean]
|
|
28
30
|
def listing_bonus? = category.listing_bonus?
|
|
29
31
|
end
|
|
30
|
-
##
|
|
31
|
-
# Recon facts used by Inspect and FeedResolution.
|
|
32
32
|
Result = Data.define(
|
|
33
33
|
:requested_url,
|
|
34
34
|
:final_url,
|
|
@@ -43,7 +43,11 @@ module Html2rss
|
|
|
43
43
|
:content_type,
|
|
44
44
|
:blocked_surface,
|
|
45
45
|
:sst
|
|
46
|
-
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Recon facts used by Inspect and FeedResolution.
|
|
50
|
+
class Result
|
|
47
51
|
##
|
|
48
52
|
# @return [Hash{Symbol => Object}]
|
|
49
53
|
def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- omit-empty optional keys
|
data/lib/html2rss/recon.rb
CHANGED
|
@@ -16,27 +16,14 @@ module Html2rss
|
|
|
16
16
|
##
|
|
17
17
|
# Closed curation verdict (:build / :defer / :drop).
|
|
18
18
|
class Verdict
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class << self
|
|
23
|
-
##
|
|
24
|
-
# @param value [Verdict, Symbol, String]
|
|
25
|
-
# @return [Verdict]
|
|
26
|
-
def coerce(value)
|
|
27
|
-
return value if value.is_a?(self)
|
|
28
|
-
|
|
29
|
-
new(name: value.to_sym)
|
|
30
|
-
end
|
|
31
|
-
end
|
|
19
|
+
# Frozen name → instance table.
|
|
20
|
+
ALL = %i[build defer drop].to_h { |name| [name, new(name:)] }.freeze
|
|
21
|
+
private_class_method :new
|
|
32
22
|
|
|
33
23
|
##
|
|
34
|
-
# @param name [Symbol]
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
super
|
|
39
|
-
end
|
|
24
|
+
# @param name [Symbol, String]
|
|
25
|
+
# @return [Verdict]
|
|
26
|
+
def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown verdict: #{name.inspect}" }
|
|
40
27
|
|
|
41
28
|
##
|
|
42
29
|
# @return [Boolean]
|
|
@@ -59,8 +46,6 @@ module Html2rss
|
|
|
59
46
|
def to_s = name.to_s
|
|
60
47
|
end
|
|
61
48
|
|
|
62
|
-
##
|
|
63
|
-
# Immutable outcome of a reconnaissance operation.
|
|
64
49
|
Result = Data.define(
|
|
65
50
|
:requested_url,
|
|
66
51
|
:final_url,
|
|
@@ -72,7 +57,11 @@ module Html2rss
|
|
|
72
57
|
:scheme_downgrade,
|
|
73
58
|
:notes,
|
|
74
59
|
:html_bytesize
|
|
75
|
-
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# Immutable outcome of a reconnaissance operation.
|
|
64
|
+
class Result
|
|
76
65
|
##
|
|
77
66
|
# @return [Boolean]
|
|
78
67
|
def build? = verdict.build?
|
|
@@ -111,7 +100,7 @@ module Html2rss
|
|
|
111
100
|
# Runs reconnaissance on a single URL.
|
|
112
101
|
#
|
|
113
102
|
# @param url [String, Html2rss::Url] source page URL
|
|
114
|
-
# @param strategy [Symbol] request strategy (:auto, :
|
|
103
|
+
# @param strategy [Symbol] request strategy (:auto, :default, :botasaurus)
|
|
115
104
|
# @param cache_dir [String, nil] optional directory to cache raw HTML bodies
|
|
116
105
|
# @param cache_mutex [Mutex, nil] optional mutex serializing cache writes
|
|
117
106
|
# @option options [Integer, nil] :max_redirects optional maximum redirects
|
|
@@ -140,7 +129,7 @@ module Html2rss
|
|
|
140
129
|
status: probe.response.status,
|
|
141
130
|
verdict:,
|
|
142
131
|
native_feed:,
|
|
143
|
-
surface_category: SurfaceCategory
|
|
132
|
+
surface_category: SurfaceCategory[recon.surface_category],
|
|
144
133
|
articles_count: recon.articles_count,
|
|
145
134
|
scheme_downgrade: recon.scheme_downgrade,
|
|
146
135
|
notes:,
|
|
@@ -204,13 +193,13 @@ module Html2rss
|
|
|
204
193
|
private_class_method :build_notes
|
|
205
194
|
|
|
206
195
|
def determine_verdict(recon, native_feed)
|
|
207
|
-
return Verdict
|
|
208
|
-
return Verdict
|
|
196
|
+
return Verdict[:drop] if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
|
|
197
|
+
return Verdict[:defer] if native_feed
|
|
209
198
|
|
|
210
|
-
category = SurfaceCategory
|
|
211
|
-
return Verdict
|
|
199
|
+
category = SurfaceCategory[recon.surface_category]
|
|
200
|
+
return Verdict[:drop] if category.blocked?
|
|
212
201
|
|
|
213
|
-
Verdict
|
|
202
|
+
Verdict[:build]
|
|
214
203
|
end
|
|
215
204
|
private_class_method :determine_verdict
|
|
216
205
|
|
|
@@ -219,9 +208,9 @@ module Html2rss
|
|
|
219
208
|
requested_url: url_obj,
|
|
220
209
|
final_url: url_obj,
|
|
221
210
|
status: nil,
|
|
222
|
-
verdict: Verdict
|
|
211
|
+
verdict: Verdict[:drop],
|
|
223
212
|
native_feed: nil,
|
|
224
|
-
surface_category: SurfaceCategory
|
|
213
|
+
surface_category: SurfaceCategory[:unsupported_surface],
|
|
225
214
|
articles_count: 0,
|
|
226
215
|
scheme_downgrade: false,
|
|
227
216
|
notes: ["error: #{error.class} - #{error.message}"],
|
|
@@ -9,11 +9,11 @@ module Html2rss
|
|
|
9
9
|
# +ScrapeRequest+ / +ScrapeSuccess+ / +ScrapeError+ (sibling +openapi.yaml+).
|
|
10
10
|
class BotasaurusContract
|
|
11
11
|
# Closed set from OpenAPI ExecutionMode.
|
|
12
|
-
EXECUTION_MODES =
|
|
12
|
+
EXECUTION_MODES = Set['auto', 'request', 'browser'].freeze
|
|
13
13
|
# Closed set from OpenAPI NavigationMode.
|
|
14
|
-
NAVIGATION_MODES =
|
|
14
|
+
NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass', 'organic_get'].freeze
|
|
15
15
|
# Closed set from OpenAPI ErrorCategory.
|
|
16
|
-
ERROR_CATEGORIES =
|
|
16
|
+
ERROR_CATEGORIES = Set['timeout', 'challenge_block', 'navigation_error', 'metadata_error', 'validation'].freeze
|
|
17
17
|
|
|
18
18
|
# ScrapeRequest properties except +url+ (html2rss supplies the target URL).
|
|
19
19
|
REQUEST_OPTION_KEYS = %i[
|
|
@@ -25,7 +25,7 @@ module Html2rss
|
|
|
25
25
|
# OpenAPI WindowSize required keys (positive integers).
|
|
26
26
|
WINDOW_SIZE_PROPERTIES = %i[width height].freeze
|
|
27
27
|
|
|
28
|
-
#
|
|
28
|
+
# HTTPX POST /scrape cap mirroring botasaurus-scrape-api total scrape wall (`SCRAPE_TIMEOUT_SECONDS`).
|
|
29
29
|
SCRAPE_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_TIMEOUT_SECONDS', 45))
|
|
30
30
|
# Post-boot navigate/wait budget mirrored from scrape-api (`SCRAPE_WORK_TIMEOUT_SECONDS`).
|
|
31
31
|
SCRAPE_WORK_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_WORK_TIMEOUT_SECONDS', 30))
|
|
@@ -72,10 +72,7 @@ module Html2rss
|
|
|
72
72
|
|
|
73
73
|
# @return [Hash{String => String}] normalized response headers (null → {})
|
|
74
74
|
def headers
|
|
75
|
-
|
|
76
|
-
return {} unless raw_headers.is_a?(Hash) && raw_headers.any?
|
|
77
|
-
|
|
78
|
-
raw_headers.to_h { |key, value| [key.to_s, value.to_s] }
|
|
75
|
+
stringify_headers(payload['headers'])
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
# @return [Integer, nil] document status_code when present
|
|
@@ -147,10 +144,12 @@ module Html2rss
|
|
|
147
144
|
entry[key.to_s] || entry[key]
|
|
148
145
|
end
|
|
149
146
|
|
|
150
|
-
def xhr_headers(raw)
|
|
151
|
-
|
|
147
|
+
def xhr_headers(raw) = stringify_headers(raw)
|
|
148
|
+
|
|
149
|
+
def stringify_headers(raw)
|
|
150
|
+
return {} unless raw.is_a?(Hash) && raw.any?
|
|
152
151
|
|
|
153
|
-
raw.
|
|
152
|
+
raw.transform_keys(&:to_s).transform_values(&:to_s)
|
|
154
153
|
end
|
|
155
154
|
|
|
156
155
|
def xhr_status_code(value)
|
|
@@ -286,7 +285,7 @@ module Html2rss
|
|
|
286
285
|
payload
|
|
287
286
|
end
|
|
288
287
|
|
|
289
|
-
# @param transport_response [
|
|
288
|
+
# @param transport_response [HTTPX::Response] upstream HTTP response
|
|
290
289
|
# @return [Success, Error]
|
|
291
290
|
# @raise [BotasaurusServiceError] when payload is not a scrape envelope
|
|
292
291
|
def parse_response(transport_response)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
3
|
+
require 'httpx'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
class BotasaurusStrategy < Strategy
|
|
12
12
|
# Content-Type negotiation for the scrape API transport hop.
|
|
13
13
|
TRANSPORT_ACCEPT = 'application/json'
|
|
14
|
-
# Disable compressed bodies so
|
|
14
|
+
# Disable compressed bodies so HTTPX returns raw JSON without implicit decoding surprises.
|
|
15
15
|
TRANSPORT_ENCODING = 'identity'
|
|
16
16
|
# Correlates each POST /scrape with botasaurus-scrape-api request logs.
|
|
17
17
|
REQUEST_ID_HEADER = 'X-Request-Id'
|
|
@@ -28,10 +28,15 @@ module Html2rss
|
|
|
28
28
|
def post_scrape_request
|
|
29
29
|
request_id = SecureRandom.uuid
|
|
30
30
|
Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
payload = JSON.generate(contract.request_payload)
|
|
32
|
+
response = client.post(scrape_endpoint, body: payload, headers: post_headers(request_id))
|
|
33
|
+
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
34
|
+
|
|
35
|
+
contract.parse_response(response)
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
def scrape_endpoint = "#{scraper_base_url.to_s.chomp('/')}/scrape"
|
|
39
|
+
|
|
35
40
|
def build_response(parsed_response)
|
|
36
41
|
Response.new(
|
|
37
42
|
body: parsed_response.html,
|
|
@@ -44,22 +49,14 @@ module Html2rss
|
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def raise_from_error!(error)
|
|
47
|
-
|
|
48
|
-
raise_if_timed_out!(error)
|
|
49
|
-
raise BotasaurusServiceError, error.failure_message
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def raise_if_challenge_blocked!(error)
|
|
53
|
-
return unless error.challenge_block?
|
|
52
|
+
raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}" if error.challenge_block?
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return unless error.timeout?
|
|
54
|
+
if error.timeout?
|
|
55
|
+
log_timeout!(reason: 'botasaurus_upstream')
|
|
56
|
+
raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
|
|
57
|
+
end
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
|
|
59
|
+
raise BotasaurusServiceError, error.failure_message
|
|
63
60
|
end
|
|
64
61
|
|
|
65
62
|
def response_url(final_url)
|
|
@@ -79,8 +76,18 @@ module Html2rss
|
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
def client
|
|
82
|
-
|
|
83
|
-
@client ||=
|
|
79
|
+
timeout = attempt_timeout_seconds.to_f
|
|
80
|
+
@client ||= HTTPX.with(headers: client_headers, timeout: client_timeouts(timeout), resolver_class: :system)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def client_timeouts(timeout)
|
|
84
|
+
{
|
|
85
|
+
operation_timeout: timeout,
|
|
86
|
+
connect_timeout: [ctx.policy.connect_timeout_seconds, timeout].min,
|
|
87
|
+
read_timeout: timeout,
|
|
88
|
+
request_timeout: timeout,
|
|
89
|
+
total_request_timeout: timeout
|
|
90
|
+
}
|
|
84
91
|
end
|
|
85
92
|
|
|
86
93
|
def client_headers
|
|
@@ -92,14 +99,7 @@ module Html2rss
|
|
|
92
99
|
end
|
|
93
100
|
|
|
94
101
|
def post_headers(request_id)
|
|
95
|
-
{
|
|
96
|
-
'Content-Type' => 'application/json',
|
|
97
|
-
REQUEST_ID_HEADER => request_id
|
|
98
|
-
}
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def request_options
|
|
102
|
-
{ timeout: attempt_timeout_seconds.to_i }
|
|
102
|
+
{ 'Content-Type' => 'application/json', REQUEST_ID_HEADER => request_id }
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def attempt_timeout_seconds
|
|
@@ -123,6 +123,10 @@ module Html2rss
|
|
|
123
123
|
def translate_connection_error(error)
|
|
124
124
|
raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
|
|
125
125
|
end
|
|
126
|
+
|
|
127
|
+
def timeout_error?(error) = error.is_a?(HTTPX::TimeoutError) || super
|
|
128
|
+
|
|
129
|
+
def connection_error?(error) = error.is_a?(HTTPX::ConnectionError) || error.is_a?(HTTPX::TLSError) || super
|
|
126
130
|
end
|
|
127
131
|
end
|
|
128
132
|
end
|
|
@@ -45,9 +45,12 @@ module Html2rss
|
|
|
45
45
|
|
|
46
46
|
def inflate(raw, encoding)
|
|
47
47
|
case encoding
|
|
48
|
-
when 'gzip'
|
|
49
|
-
|
|
48
|
+
when 'gzip'
|
|
49
|
+
return raw unless raw.start_with?(GZIP_MAGIC)
|
|
50
|
+
|
|
51
|
+
uncompress_gzip(raw)
|
|
50
52
|
when 'br' then try_brotli(raw, require_html: false)
|
|
53
|
+
when 'deflate' then inflate_deflate(raw)
|
|
51
54
|
end
|
|
52
55
|
rescue Zlib::Error, Brotli::Error, ArgumentError
|
|
53
56
|
nil
|
|
@@ -55,12 +58,6 @@ module Html2rss
|
|
|
55
58
|
module_function :inflate
|
|
56
59
|
private_class_method :inflate
|
|
57
60
|
|
|
58
|
-
def uncompress_gzip(raw)
|
|
59
|
-
Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
|
|
60
|
-
end
|
|
61
|
-
module_function :uncompress_gzip
|
|
62
|
-
private_class_method :uncompress_gzip
|
|
63
|
-
|
|
64
61
|
def inflate_deflate(raw)
|
|
65
62
|
inflater = nil
|
|
66
63
|
Zlib::Inflate.inflate(raw)
|
|
@@ -73,6 +70,14 @@ module Html2rss
|
|
|
73
70
|
module_function :inflate_deflate
|
|
74
71
|
private_class_method :inflate_deflate
|
|
75
72
|
|
|
73
|
+
def uncompress_gzip(raw)
|
|
74
|
+
Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
|
|
75
|
+
rescue Zlib::Error
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
module_function :uncompress_gzip
|
|
79
|
+
private_class_method :uncompress_gzip
|
|
80
|
+
|
|
76
81
|
def try_brotli(raw, require_html: true)
|
|
77
82
|
inflated = Brotli.inflate(raw)
|
|
78
83
|
return inflated unless require_html
|
|
@@ -86,7 +91,10 @@ module Html2rss
|
|
|
86
91
|
private_class_method :try_brotli
|
|
87
92
|
|
|
88
93
|
def content_encodings(headers)
|
|
89
|
-
header(headers, 'content-encoding')
|
|
94
|
+
header(headers, 'content-encoding')
|
|
95
|
+
.to_s
|
|
96
|
+
.split(',')
|
|
97
|
+
.filter_map { |val| (s = val.strip.downcase).empty? ? nil : s }
|
|
90
98
|
end
|
|
91
99
|
module_function :content_encodings
|
|
92
100
|
private_class_method :content_encodings
|