html2rss 0.30.0 → 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 +2 -2
- 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 +3 -1
- data/lib/html2rss/capture/README.md +6 -4
- data/lib/html2rss/capture.rb +67 -35
- 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 +2 -3
- 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 -113
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +6 -4
- 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/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
- 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 +2 -0
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +6 -20
- data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +111 -131
- data/lib/html2rss/mcp/server.rb +26 -18
- 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 +19 -30
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/compressed_body.rb +4 -1
- data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/strategy.rb +3 -8
- 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 -173
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +21 -13
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +15 -6
- data/lib/html2rss/selectors/item_scope.rb +0 -30
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?
|
|
@@ -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)
|
|
@@ -84,7 +84,7 @@ module Html2rss
|
|
|
84
84
|
{
|
|
85
85
|
operation_timeout: timeout,
|
|
86
86
|
connect_timeout: [ctx.policy.connect_timeout_seconds, timeout].min,
|
|
87
|
-
read_timeout:
|
|
87
|
+
read_timeout: timeout,
|
|
88
88
|
request_timeout: timeout,
|
|
89
89
|
total_request_timeout: timeout
|
|
90
90
|
}
|
|
@@ -91,7 +91,10 @@ module Html2rss
|
|
|
91
91
|
private_class_method :try_brotli
|
|
92
92
|
|
|
93
93
|
def content_encodings(headers)
|
|
94
|
-
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 }
|
|
95
98
|
end
|
|
96
99
|
module_function :content_encodings
|
|
97
100
|
private_class_method :content_encodings
|
|
@@ -5,7 +5,6 @@ require 'httpx/plugins/follow_redirects'
|
|
|
5
5
|
require 'httpx/plugins/callbacks'
|
|
6
6
|
require 'httpx/plugins/ssrf_filter'
|
|
7
7
|
require 'httpx/plugins/brotli'
|
|
8
|
-
require 'httpx/plugins/fiber_concurrency'
|
|
9
8
|
require 'httpx/plugins/retries'
|
|
10
9
|
|
|
11
10
|
module Html2rss
|
|
@@ -16,39 +15,46 @@ module Html2rss
|
|
|
16
15
|
# and redirect handling without monkey-patching.
|
|
17
16
|
# rubocop:disable-next Metrics/ClassLength -- terminal redirect retry colocated with HTTPX transport
|
|
18
17
|
class HttpxStrategy < Strategy
|
|
18
|
+
# HTTPX default; kept explicit so ping reconnects cannot omit a bound.
|
|
19
|
+
MAX_RETRIES = 3
|
|
20
|
+
# Forces the selector timer retry path. Unset retry_after recurses in fetch_response;
|
|
21
|
+
# combined with ping! on a reused closed selector that never decrements retries,
|
|
22
|
+
# that overflows the stack and kills Falcon workers.
|
|
23
|
+
RETRY_AFTER_SECONDS = 0.25
|
|
24
|
+
# Bounds passed to the retries plugin and every +session.with+.
|
|
25
|
+
# HTTPX retries stay inside one {RequestService::Budget} slot and the shared
|
|
26
|
+
# wall-clock deadline; do not consume a slot per attempt.
|
|
27
|
+
# Do not reload +:fiber_concurrency+: a reused closed selector made +ping!+
|
|
28
|
+
# skip decrementing retries and recurse until +SystemStackError+.
|
|
29
|
+
#
|
|
30
|
+
# @return [Hash{Symbol => Integer, Float}]
|
|
31
|
+
RETRY_OPTIONS = { max_retries: MAX_RETRIES, retry_after: RETRY_AFTER_SECONDS }.freeze
|
|
32
|
+
|
|
19
33
|
class << self
|
|
20
34
|
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
21
35
|
# @return [HTTPX::Session]
|
|
22
36
|
def base_session
|
|
23
|
-
@
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.plugin(:fiber_concurrency)
|
|
29
|
-
.plugin(:retries)
|
|
37
|
+
@base_session ||= HTTPX
|
|
38
|
+
.plugin(:follow_redirects)
|
|
39
|
+
.plugin(:callbacks)
|
|
40
|
+
.plugin(:brotli)
|
|
41
|
+
.plugin(:retries, **RETRY_OPTIONS)
|
|
30
42
|
end
|
|
31
43
|
|
|
32
44
|
# @return [HTTPX::Session]
|
|
33
45
|
def base_ssrf_session
|
|
34
|
-
@
|
|
35
|
-
@base_ssrf_sessions[HTTPX::Session] ||= base_session.plugin(:ssrf_filter)
|
|
46
|
+
@base_ssrf_session ||= base_session.plugin(:ssrf_filter)
|
|
36
47
|
end
|
|
37
48
|
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
38
49
|
end
|
|
39
50
|
|
|
40
|
-
##
|
|
41
|
-
# @return [ResponseGuard]
|
|
42
|
-
attr_reader :response_guard
|
|
43
|
-
|
|
44
51
|
# Executes the request with runtime policy enforcement, returning the normalized response.
|
|
45
52
|
#
|
|
46
53
|
# @return [Response] normalized response
|
|
47
54
|
def perform_execute
|
|
48
55
|
deadline = request_deadline
|
|
49
|
-
@response_guard = ResponseGuard.new(policy: ctx.policy)
|
|
50
56
|
reset_redirect_tracking!
|
|
51
|
-
raw_response = request_with_terminal_redirect_retry(
|
|
57
|
+
raw_response = request_with_terminal_redirect_retry(deadline:)
|
|
52
58
|
build_response(raw_response)
|
|
53
59
|
end
|
|
54
60
|
|
|
@@ -65,15 +71,15 @@ module Html2rss
|
|
|
65
71
|
@current_url = request_url
|
|
66
72
|
end
|
|
67
73
|
|
|
68
|
-
def request_with_terminal_redirect_retry(
|
|
69
|
-
raw_response = execute_http_request(
|
|
74
|
+
def request_with_terminal_redirect_retry(deadline:)
|
|
75
|
+
raw_response = execute_http_request(deadline:)
|
|
70
76
|
return raw_response unless redirect_limit_reached?(raw_response)
|
|
71
77
|
|
|
72
78
|
unless terminal_redirect_retryable?(raw_response)
|
|
73
79
|
raise RedirectLimitReached, "Too many redirects (status #{raw_response.status})"
|
|
74
80
|
end
|
|
75
81
|
|
|
76
|
-
retry_from_terminal_redirect!(raw_response,
|
|
82
|
+
retry_from_terminal_redirect!(raw_response, deadline:)
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
def redirect_limit_reached?(response)
|
|
@@ -82,7 +88,7 @@ module Html2rss
|
|
|
82
88
|
return false unless (300..399).cover?(response.status)
|
|
83
89
|
|
|
84
90
|
location = response.headers['location']
|
|
85
|
-
!location.
|
|
91
|
+
!location.to_s.strip.empty?
|
|
86
92
|
end
|
|
87
93
|
|
|
88
94
|
def terminal_redirect_url(raw_response)
|
|
@@ -102,12 +108,12 @@ module Html2rss
|
|
|
102
108
|
target && target.to_s != request_url.to_s
|
|
103
109
|
end
|
|
104
110
|
|
|
105
|
-
def retry_from_terminal_redirect!(raw_response,
|
|
111
|
+
def retry_from_terminal_redirect!(raw_response, deadline:)
|
|
106
112
|
terminal_url = terminal_redirect_url(raw_response)
|
|
107
113
|
@terminal_redirect_retried = true
|
|
108
114
|
Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
|
|
109
115
|
begin_terminal_url_request!(terminal_url)
|
|
110
|
-
new_response = execute_http_request(
|
|
116
|
+
new_response = execute_http_request(deadline:, consume_budget: false)
|
|
111
117
|
if redirect_limit_reached?(new_response)
|
|
112
118
|
raise RedirectLimitReached, "Too many redirects (status #{new_response.status})"
|
|
113
119
|
end
|
|
@@ -126,17 +132,21 @@ module Html2rss
|
|
|
126
132
|
@request_url_override || ctx.url
|
|
127
133
|
end
|
|
128
134
|
|
|
129
|
-
def execute_http_request(
|
|
135
|
+
def execute_http_request(deadline:, consume_budget: true)
|
|
130
136
|
preflight!(consume_budget:)
|
|
131
|
-
session =
|
|
132
|
-
response = session
|
|
137
|
+
session = session_client(deadline)
|
|
138
|
+
response = http_get(session)
|
|
133
139
|
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
134
140
|
|
|
135
141
|
response
|
|
136
142
|
end
|
|
137
143
|
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
# GET only. Overflow here is HTTPX retry recursion; the same error from
|
|
145
|
+
# redirect handling or body decode must stay loud.
|
|
146
|
+
def http_get(session)
|
|
147
|
+
session.get(request_url.to_s, headers: ctx.headers)
|
|
148
|
+
rescue SystemStackError
|
|
149
|
+
raise HTTPX::ConnectionError, 'HTTPX retry recursion exhausted'
|
|
140
150
|
end
|
|
141
151
|
|
|
142
152
|
def session_client(deadline)
|
|
@@ -152,7 +162,8 @@ module Html2rss
|
|
|
152
162
|
follow_insecure_redirects: false,
|
|
153
163
|
max_response_body_size: ctx.policy.max_response_bytes,
|
|
154
164
|
resolver_class: :system,
|
|
155
|
-
redirect_on: redirect_callback
|
|
165
|
+
redirect_on: redirect_callback,
|
|
166
|
+
**RETRY_OPTIONS
|
|
156
167
|
}
|
|
157
168
|
end
|
|
158
169
|
|
|
@@ -13,7 +13,7 @@ module Html2rss
|
|
|
13
13
|
# {Policy} so SSRF/origin rules stay single-homed on the public façade.
|
|
14
14
|
class NetworkGuard
|
|
15
15
|
# Hostnames treated as local/private surfaces.
|
|
16
|
-
LOCAL_HOSTS =
|
|
16
|
+
LOCAL_HOSTS = Set['localhost', 'localhost.localdomain', 'metadata.google.internal'].freeze
|
|
17
17
|
# IP ranges blocked when private networks are disabled.
|
|
18
18
|
BLOCKED_IP_RANGES = [
|
|
19
19
|
IPAddr.new('0.0.0.0/8'),
|
|
@@ -32,7 +32,9 @@ module Html2rss
|
|
|
32
32
|
|
|
33
33
|
##
|
|
34
34
|
# @param allow_private_networks [Boolean] whether private network targets are allowed
|
|
35
|
-
# @param resolver [
|
|
35
|
+
# @param resolver [Socket, Resolv] DNS resolver used for hostname classification.
|
|
36
|
+
# +Socket+ (the default) uses +getaddrinfo+; any other resolver is Resolv-shaped
|
|
37
|
+
# and must implement +each_address+.
|
|
36
38
|
def initialize(allow_private_networks:, resolver: Socket)
|
|
37
39
|
@allow_private_networks = allow_private_networks ? true : false
|
|
38
40
|
@resolver = resolver
|
|
@@ -89,10 +91,10 @@ module Html2rss
|
|
|
89
91
|
literal = parse_ip(host)
|
|
90
92
|
return [literal] if literal
|
|
91
93
|
|
|
92
|
-
if resolver
|
|
93
|
-
addresses_from_each_address(host)
|
|
94
|
-
else
|
|
94
|
+
if resolver == Socket
|
|
95
95
|
addresses_from_getaddrinfo(host)
|
|
96
|
+
else
|
|
97
|
+
addresses_from_each_address(host)
|
|
96
98
|
end
|
|
97
99
|
rescue Resolv::ResolvError, SocketError, SystemCallError
|
|
98
100
|
[]
|
|
@@ -36,8 +36,7 @@ module Html2rss
|
|
|
36
36
|
captured_responses: EMPTY_CAPTURED_RESPONSES)
|
|
37
37
|
@body = body
|
|
38
38
|
|
|
39
|
-
headers = headers.
|
|
40
|
-
headers.transform_keys!(&:to_s)
|
|
39
|
+
headers = (headers || {}).transform_keys(&:to_s)
|
|
41
40
|
HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
|
|
42
41
|
|
|
43
42
|
@headers = headers
|
|
@@ -22,7 +22,7 @@ module Html2rss
|
|
|
22
22
|
def execute
|
|
23
23
|
check_timeout!
|
|
24
24
|
response = perform_execute
|
|
25
|
-
postflight!(response
|
|
25
|
+
postflight!(response)
|
|
26
26
|
response
|
|
27
27
|
rescue StandardError => error
|
|
28
28
|
handle_error(error)
|
|
@@ -67,13 +67,11 @@ module Html2rss
|
|
|
67
67
|
# Enforces response-size and blocked-surface checks after transport work.
|
|
68
68
|
#
|
|
69
69
|
# @param response [Response, nil] adapter response
|
|
70
|
-
# @param response_guard [ResponseGuard, nil] existing guard to reuse (e.g. streaming)
|
|
71
70
|
# @return [void]
|
|
72
|
-
def postflight!(response
|
|
71
|
+
def postflight!(response)
|
|
73
72
|
return if skip_postflight? || response.nil?
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
guard.inspect_body!(response.body)
|
|
74
|
+
ResponseGuard.new(policy: ctx.policy).inspect_body!(response.body)
|
|
77
75
|
end
|
|
78
76
|
|
|
79
77
|
##
|
|
@@ -91,9 +89,6 @@ module Html2rss
|
|
|
91
89
|
raise
|
|
92
90
|
end
|
|
93
91
|
|
|
94
|
-
# @return [ResponseGuard, nil]
|
|
95
|
-
def response_guard = nil
|
|
96
|
-
|
|
97
92
|
# @param error [StandardError]
|
|
98
93
|
# @return [void]
|
|
99
94
|
# @raise [StandardError]
|
|
@@ -10,14 +10,14 @@ module Html2rss
|
|
|
10
10
|
# Minimum composite score retained by {#rank_top} (precision floor).
|
|
11
11
|
SCORE_FLOOR = 0.0
|
|
12
12
|
|
|
13
|
-
FEATURE_IDS =
|
|
14
|
-
title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
|
|
15
|
-
publish_marker descriptive_context article_container content_tokens
|
|
16
|
-
non_content_utility_path utility_prefix_title_short shallow weak_container
|
|
17
|
-
recommended_title_non_content high_confidence_junk_path junk_tokens
|
|
18
|
-
heading_anchor heading_text_match meaningful_text content_like_destination
|
|
19
|
-
cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
|
|
20
|
-
].
|
|
13
|
+
FEATURE_IDS = Set[
|
|
14
|
+
:title_word_count_ge3, :title_word_count_ge7, :path_length_gt6, :content_path,
|
|
15
|
+
:publish_marker, :descriptive_context, :article_container, :content_tokens,
|
|
16
|
+
:non_content_utility_path, :utility_prefix_title_short, :shallow, :weak_container,
|
|
17
|
+
:recommended_title_non_content, :high_confidence_junk_path, :junk_tokens,
|
|
18
|
+
:heading_anchor, :heading_text_match, :meaningful_text, :content_like_destination,
|
|
19
|
+
:cluster_size, :cluster_avg_words, :cluster_heading, :cluster_time, :cluster_date
|
|
20
|
+
].freeze
|
|
21
21
|
private_constant :FEATURE_IDS
|
|
22
22
|
|
|
23
23
|
# Quality feature triples: [feature_id, predicate, weight].
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module Scoring
|
|
5
|
-
##
|
|
6
|
-
# Typed container signals produced by {ContainerAssessor} for {Engine} rules.
|
|
7
5
|
Observation = Data.define(
|
|
8
6
|
:title_word_count,
|
|
9
7
|
:path_length,
|
|
@@ -21,7 +19,11 @@ module Html2rss
|
|
|
21
19
|
:high_confidence_junk_path,
|
|
22
20
|
:high_confidence_utility_destination,
|
|
23
21
|
:selected_anchor_present
|
|
24
|
-
)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# Typed container signals produced by {ContainerAssessor} for {Engine} rules.
|
|
26
|
+
class Observation
|
|
25
27
|
##
|
|
26
28
|
# @return [Boolean] true when fewer than two positive content signals are present
|
|
27
29
|
def weak_signals?
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module Scoring
|
|
5
|
+
RankedSegment = Data.define(:segment, :score)
|
|
6
|
+
|
|
5
7
|
##
|
|
6
8
|
# A segment paired with its composite score.
|
|
7
|
-
RankedSegment
|
|
9
|
+
class RankedSegment
|
|
8
10
|
##
|
|
9
11
|
# @return [SST::Node]
|
|
10
12
|
def root_node = segment.root_node
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
|
+
ScrapeTarget = Data.define(:entry_url, :effective_url)
|
|
5
|
+
|
|
4
6
|
##
|
|
5
7
|
# Immutable entry vs effective scrape URLs for one pipeline run.
|
|
6
8
|
#
|
|
7
9
|
# Replaces mutating +Config#scrape_url=+ after {FeedResolution} rewrites the fetch URL.
|
|
8
|
-
ScrapeTarget
|
|
10
|
+
class ScrapeTarget
|
|
9
11
|
##
|
|
10
12
|
# @param config [Html2rss::Config]
|
|
11
13
|
# @return [ScrapeTarget]
|