html2rss 0.32.4 → 0.34.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 +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -25
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +127 -53
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +50 -7
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +55 -51
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +111 -42
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -9,18 +9,26 @@ module Html2rss
|
|
|
9
9
|
#
|
|
10
10
|
# Public façade for request limits, same-origin/follow-up rules, and
|
|
11
11
|
# network reachability. DNS/host/IP enforcement lives on {NetworkGuard}.
|
|
12
|
+
#
|
|
13
|
+
# Follow-ups stay on the origin (scheme, host, port) unless the relation is
|
|
14
|
+
# +:advertised_feed+ and the URL is listed in the page's {FeedAdvertisement}; redirects
|
|
15
|
+
# of such a request may change host over +https+. Budget, scheme, public-network
|
|
16
|
+
# and response-size guards apply to every request and hop either way.
|
|
12
17
|
class Policy
|
|
13
18
|
# Hard ceiling for configured max_requests during a feed build.
|
|
14
19
|
MAX_REQUESTS_CEILING = 10
|
|
15
20
|
|
|
21
|
+
# Default response-size ceiling (10 MiB); owns both streamed and decompressed defaults.
|
|
22
|
+
MAX_RESPONSE_BYTES = 10 * 1024 * 1024
|
|
23
|
+
|
|
16
24
|
# Default policy values used when request controls are not explicitly set.
|
|
17
25
|
DEFAULTS = {
|
|
18
26
|
connect_timeout_seconds: Integer(ENV.fetch('HTML2RSS_CONNECT_TIMEOUT_SECONDS', 5)),
|
|
19
27
|
read_timeout_seconds: Integer(ENV.fetch('HTML2RSS_READ_TIMEOUT_SECONDS', 10)),
|
|
20
28
|
total_timeout_seconds: Integer(ENV.fetch('HTML2RSS_TOTAL_TIMEOUT_SECONDS', 30)),
|
|
21
29
|
max_redirects: 5,
|
|
22
|
-
max_response_bytes:
|
|
23
|
-
max_decompressed_bytes:
|
|
30
|
+
max_response_bytes: MAX_RESPONSE_BYTES,
|
|
31
|
+
max_decompressed_bytes: MAX_RESPONSE_BYTES,
|
|
24
32
|
max_requests: 1,
|
|
25
33
|
allow_private_networks: false,
|
|
26
34
|
allow_cross_origin_followups: false
|
|
@@ -47,13 +55,13 @@ module Html2rss
|
|
|
47
55
|
allow_private_networks: DEFAULTS[:allow_private_networks],
|
|
48
56
|
allow_cross_origin_followups: DEFAULTS[:allow_cross_origin_followups],
|
|
49
57
|
resolver: Socket)
|
|
50
|
-
@connect_timeout_seconds =
|
|
51
|
-
@read_timeout_seconds =
|
|
52
|
-
@total_timeout_seconds =
|
|
53
|
-
@max_redirects =
|
|
54
|
-
@max_response_bytes =
|
|
55
|
-
@max_decompressed_bytes =
|
|
56
|
-
@max_requests = [
|
|
58
|
+
@connect_timeout_seconds = Option.integer(:connect_timeout_seconds, connect_timeout_seconds, min: 1)
|
|
59
|
+
@read_timeout_seconds = Option.integer(:read_timeout_seconds, read_timeout_seconds, min: 1)
|
|
60
|
+
@total_timeout_seconds = Option.integer(:total_timeout_seconds, total_timeout_seconds, min: 1)
|
|
61
|
+
@max_redirects = Option.integer(:max_redirects, max_redirects, min: 0)
|
|
62
|
+
@max_response_bytes = Option.integer(:max_response_bytes, max_response_bytes, min: 1)
|
|
63
|
+
@max_decompressed_bytes = Option.integer(:max_decompressed_bytes, max_decompressed_bytes, min: 1)
|
|
64
|
+
@max_requests = [Option.integer(:max_requests, max_requests, min: 1), MAX_REQUESTS_CEILING].min
|
|
57
65
|
@allow_private_networks = allow_private_networks ? true : false
|
|
58
66
|
@allow_cross_origin_followups = allow_cross_origin_followups ? true : false
|
|
59
67
|
@network_guard = NetworkGuard.new(allow_private_networks: @allow_private_networks, resolver:)
|
|
@@ -80,6 +88,12 @@ module Html2rss
|
|
|
80
88
|
@allow_cross_origin_followups
|
|
81
89
|
end
|
|
82
90
|
|
|
91
|
+
##
|
|
92
|
+
# @return [Array] the policy fields that shape a fetched response (for response caching)
|
|
93
|
+
def cache_key
|
|
94
|
+
[allow_private_networks?, max_redirects, max_response_bytes, max_decompressed_bytes]
|
|
95
|
+
end
|
|
96
|
+
|
|
83
97
|
##
|
|
84
98
|
# Returns the default request policy.
|
|
85
99
|
#
|
|
@@ -89,17 +103,30 @@ module Html2rss
|
|
|
89
103
|
new
|
|
90
104
|
end
|
|
91
105
|
|
|
106
|
+
##
|
|
107
|
+
# Whether two URLs share an origin (scheme, host, and port).
|
|
108
|
+
#
|
|
109
|
+
# @param url [Html2rss::Url]
|
|
110
|
+
# @param other [Html2rss::Url]
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def self.same_origin?(url, other)
|
|
113
|
+
[url.scheme, url.host, url.port] == [other.scheme, other.host, other.port]
|
|
114
|
+
end
|
|
115
|
+
|
|
92
116
|
##
|
|
93
117
|
# Validates whether a request target is permitted for the given context.
|
|
94
118
|
#
|
|
95
119
|
# @param url [Html2rss::Url] destination URL
|
|
96
120
|
# @param origin_url [Html2rss::Url] initial URL of the feed build
|
|
97
121
|
# @param relation [Symbol] logical reason for the request
|
|
122
|
+
# @param advertisement [FeedAdvertisement, nil] the page's advertised feeds (+:advertised_feed+ only)
|
|
98
123
|
# @return [void]
|
|
99
124
|
# @raise [CrossOriginFollowUpDenied] if a follow-up leaves the origin host
|
|
100
125
|
# @raise [PrivateNetworkDenied] if the target resolves to a private address
|
|
101
|
-
def validate_request!(url:, origin_url:, relation:)
|
|
102
|
-
enforce_same_origin!(url, origin_url, relation)
|
|
126
|
+
def validate_request!(url:, origin_url:, relation:, advertisement: nil)
|
|
127
|
+
enforce_same_origin!(url, origin_url, relation) do
|
|
128
|
+
advertised_feed?(origin_url, relation, advertisement) && advertisement.advertises?(url)
|
|
129
|
+
end
|
|
103
130
|
network_guard.enforce_public_network!(url)
|
|
104
131
|
end
|
|
105
132
|
|
|
@@ -110,14 +137,20 @@ module Html2rss
|
|
|
110
137
|
# @param to_url [Html2rss::Url] redirect destination
|
|
111
138
|
# @param origin_url [Html2rss::Url] initial URL of the feed build
|
|
112
139
|
# @param relation [Symbol] logical reason for the request
|
|
140
|
+
# @param advertisement [FeedAdvertisement, nil] the page's advertised feeds (+:advertised_feed+ only)
|
|
113
141
|
# @return [void]
|
|
114
142
|
# @raise [UnsupportedUrlScheme] if the redirect downgrades from HTTPS to HTTP
|
|
115
|
-
|
|
143
|
+
# @raise [CrossOriginFollowUpDenied] if a follow-up hop leaves the origin host
|
|
144
|
+
# @raise [PrivateNetworkDenied] if the hop resolves to a private address
|
|
145
|
+
def validate_redirect!(from_url:, to_url:, origin_url:, relation:, advertisement: nil)
|
|
116
146
|
if from_url.scheme == 'https' && to_url.scheme == 'http'
|
|
117
147
|
raise UnsupportedUrlScheme, "Redirect downgraded from #{from_url} to #{to_url}"
|
|
118
148
|
end
|
|
119
149
|
|
|
120
|
-
|
|
150
|
+
enforce_same_origin!(to_url, origin_url, relation) do
|
|
151
|
+
advertised_feed?(origin_url, relation, advertisement) && to_url.scheme == 'https'
|
|
152
|
+
end
|
|
153
|
+
network_guard.enforce_public_network!(to_url)
|
|
121
154
|
end
|
|
122
155
|
|
|
123
156
|
##
|
|
@@ -135,42 +168,28 @@ module Html2rss
|
|
|
135
168
|
|
|
136
169
|
attr_reader :network_guard
|
|
137
170
|
|
|
138
|
-
|
|
139
|
-
raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
|
|
140
|
-
|
|
141
|
-
value
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def validate_non_negative_integer!(name, value)
|
|
145
|
-
raise ArgumentError, "#{name} must be non-negative" unless value.is_a?(Integer) && !value.negative?
|
|
146
|
-
|
|
147
|
-
value
|
|
148
|
-
end
|
|
149
|
-
|
|
171
|
+
# Yields only for a cross-origin follow-up: a truthy block admits it.
|
|
150
172
|
def enforce_same_origin!(url, origin_url, relation)
|
|
151
173
|
return if relation == :initial || allow_cross_origin_followups?
|
|
152
174
|
|
|
153
175
|
enforce_follow_up_scheme!(url, origin_url)
|
|
154
|
-
return if
|
|
176
|
+
return if self.class.same_origin?(url, origin_url) || yield
|
|
155
177
|
|
|
156
178
|
raise CrossOriginFollowUpDenied, "Cross-origin follow-up denied for #{url}"
|
|
157
179
|
end
|
|
158
180
|
|
|
181
|
+
# The proof must come from the page this follow-up originates from; an unfrozen one was not built by
|
|
182
|
+
# {FeedAdvertisement.from_document}.
|
|
183
|
+
def advertised_feed?(origin_url, relation, advertisement)
|
|
184
|
+
relation == :advertised_feed && advertisement.instance_of?(FeedAdvertisement) && advertisement.frozen? &&
|
|
185
|
+
self.class.same_origin?(advertisement.page_url, origin_url)
|
|
186
|
+
end
|
|
187
|
+
|
|
159
188
|
def enforce_follow_up_scheme!(url, origin_url)
|
|
160
189
|
return unless origin_url.scheme == 'https' && url.scheme == 'http'
|
|
161
190
|
|
|
162
191
|
raise UnsupportedUrlScheme, "Follow-up downgraded from https to http for #{url}"
|
|
163
192
|
end
|
|
164
|
-
|
|
165
|
-
def comparable_origin(url)
|
|
166
|
-
[url.host, normalized_port(url)]
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def normalized_port(url)
|
|
170
|
-
return url.port if url.port
|
|
171
|
-
|
|
172
|
-
url.scheme == 'https' ? 443 : 80
|
|
173
|
-
end
|
|
174
193
|
end
|
|
175
194
|
|
|
176
195
|
# Shared immutable policy instance used for default request execution.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestService
|
|
5
|
+
##
|
|
6
|
+
# Process-local circuit breaker for cookie replay: after {MAX_BLOCKS} consecutive replay-then-block
|
|
7
|
+
# outcomes for a jar key, replay stays off until the cookie TTL elapses.
|
|
8
|
+
class ReplayBreaker
|
|
9
|
+
# Consecutive blocked replays before the breaker opens.
|
|
10
|
+
MAX_BLOCKS = Integer(ENV.fetch('BOTASAURUS_COOKIE_REPLAY_MAX_BLOCKS', 3))
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@state = {}
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @param key [String] jar key
|
|
19
|
+
# @return [Boolean] true while replay is suspended for the key
|
|
20
|
+
def open?(key)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
open_until = @state[key]&.last
|
|
23
|
+
next false unless open_until
|
|
24
|
+
next true if now < open_until
|
|
25
|
+
|
|
26
|
+
@state.delete(key)
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param key [String] jar key
|
|
33
|
+
# @param ttl [Numeric] seconds replay stays off once the breaker trips
|
|
34
|
+
# @return [Boolean] true when this block tripped the breaker
|
|
35
|
+
def record_block(key, ttl:)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
blocks = (@state[key]&.first || 0) + 1
|
|
38
|
+
tripped = blocks >= MAX_BLOCKS
|
|
39
|
+
@state[key] = [blocks, (now + ttl if tripped)]
|
|
40
|
+
tripped
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# @param key [String] jar key
|
|
46
|
+
# @return [void]
|
|
47
|
+
def record_pass(key)
|
|
48
|
+
@mutex.synchronize { @state.delete(key) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -6,18 +6,20 @@ module Html2rss
|
|
|
6
6
|
class RequestService
|
|
7
7
|
##
|
|
8
8
|
# To be used by strategies to provide their response.
|
|
9
|
-
class Response
|
|
9
|
+
class Response # rubocop:disable Metrics/ClassLength -- transport + capture + cache fields stay co-located
|
|
10
10
|
# Default when a strategy does not attach transport telemetry.
|
|
11
11
|
EMPTY_TRANSPORT_META = {}.freeze
|
|
12
12
|
# Default when a strategy does not capture sub-resource responses.
|
|
13
13
|
EMPTY_CAPTURED_RESPONSES = [].freeze
|
|
14
|
+
# Default when a strategy returns no cookies.
|
|
15
|
+
EMPTY_COOKIES = {}.freeze
|
|
14
16
|
|
|
15
17
|
# Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
|
|
16
18
|
HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
|
|
17
|
-
# Content-Type markers for RSS/Atom syndication responses.
|
|
18
|
-
FEED_CT_MARKERS = %w[rss+xml atom+xml rss atom].freeze
|
|
19
|
+
# Content-Type markers for RSS/Atom/RDF syndication responses.
|
|
20
|
+
FEED_CT_MARKERS = %w[rss+xml atom+xml rdf+xml rss atom].freeze
|
|
19
21
|
# Body sniff for unlabeled syndication (first 800 bytes, downcased).
|
|
20
|
-
FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed '].freeze
|
|
22
|
+
FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed ', '<rdf:rdf'].freeze
|
|
21
23
|
# Charset from Content-Type or a leading <meta charset>.
|
|
22
24
|
CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
|
|
23
25
|
# Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
|
|
@@ -31,23 +33,20 @@ module Html2rss
|
|
|
31
33
|
# @param status [Integer, nil] the HTTP status code when available
|
|
32
34
|
# @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
|
|
33
35
|
# @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
|
|
34
|
-
#
|
|
36
|
+
# @param cookies [Hash{String => String}] cookies earned during the scrape (session credentials; never log)
|
|
37
|
+
# @param cache_age [Integer, nil] seconds since fetch when served from {ResponseCache}; nil when fetched live
|
|
38
|
+
# rubocop:disable-next Metrics/ParameterLists -- transport + capture fields stay co-located
|
|
35
39
|
def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
|
|
36
|
-
captured_responses: EMPTY_CAPTURED_RESPONSES)
|
|
40
|
+
captured_responses: EMPTY_CAPTURED_RESPONSES, cookies: EMPTY_COOKIES, cache_age: nil)
|
|
37
41
|
@body = body
|
|
42
|
+
@cache_age = cache_age
|
|
38
43
|
|
|
39
|
-
headers = (headers || {}).transform_keys(&:to_s)
|
|
40
|
-
HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
|
|
41
|
-
|
|
42
|
-
@headers = headers
|
|
44
|
+
@headers = (headers || {}).transform_keys(&:to_s)
|
|
43
45
|
@status = status
|
|
44
46
|
@url = url
|
|
45
|
-
@transport_meta = transport_meta
|
|
46
|
-
@
|
|
47
|
-
|
|
48
|
-
else
|
|
49
|
-
captured_responses.freeze
|
|
50
|
-
end
|
|
47
|
+
@transport_meta = freeze_or(transport_meta, EMPTY_TRANSPORT_META)
|
|
48
|
+
@cookies = freeze_or(cookies, EMPTY_COOKIES)
|
|
49
|
+
@captured_responses = freeze_or(captured_responses, EMPTY_CAPTURED_RESPONSES)
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
# @return [String] the raw body of the response
|
|
@@ -68,6 +67,19 @@ module Html2rss
|
|
|
68
67
|
# @return [Array<Hash>] captured JSON XHR/fetch responses (empty when unsupported)
|
|
69
68
|
attr_reader :captured_responses
|
|
70
69
|
|
|
70
|
+
# @return [Hash{String => String}] cookies earned during the scrape (empty when unsupported)
|
|
71
|
+
attr_reader :cookies
|
|
72
|
+
|
|
73
|
+
# @return [Integer, nil] seconds since fetch when served from {ResponseCache}; nil when fetched live
|
|
74
|
+
attr_reader :cache_age
|
|
75
|
+
|
|
76
|
+
# @param cache_age [Integer, nil] cache age of the copy (defaults to this response's)
|
|
77
|
+
# @return [Response] an independent copy that shares no parsed document with this one
|
|
78
|
+
def replica(cache_age: @cache_age)
|
|
79
|
+
self.class.new(body: body.dup, url:, headers:, status:, transport_meta:, captured_responses:, cookies:,
|
|
80
|
+
cache_age:)
|
|
81
|
+
end
|
|
82
|
+
|
|
71
83
|
# @return [String] normalized content type header value
|
|
72
84
|
def content_type = header('content-type').to_s
|
|
73
85
|
|
|
@@ -82,7 +94,7 @@ module Html2rss
|
|
|
82
94
|
content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
|
|
83
95
|
end
|
|
84
96
|
|
|
85
|
-
# @return [Boolean] whether response content is RSS/Atom (header or sniffed body)
|
|
97
|
+
# @return [Boolean] whether response content is RSS/Atom/RDF (header or sniffed body)
|
|
86
98
|
def feed_response?
|
|
87
99
|
return @feed_response if defined?(@feed_response)
|
|
88
100
|
|
|
@@ -104,6 +116,8 @@ module Html2rss
|
|
|
104
116
|
|
|
105
117
|
private
|
|
106
118
|
|
|
119
|
+
def freeze_or(value, default) = value.nil? || value.empty? ? default : value.freeze
|
|
120
|
+
|
|
107
121
|
# @param name [String] canonical header name
|
|
108
122
|
# @return [Object, nil] header value when present
|
|
109
123
|
def header(name)
|
|
@@ -158,14 +172,14 @@ module Html2rss
|
|
|
158
172
|
|
|
159
173
|
def feed_content_type?
|
|
160
174
|
ct = content_type.downcase
|
|
161
|
-
return false if ct.
|
|
175
|
+
return false if ct.include?('html') || ct.include?('json')
|
|
162
176
|
|
|
163
177
|
# Substring match against Content-Type (not Array#intersect? on a String).
|
|
164
178
|
FEED_CT_MARKERS.any? { |marker| ct.include?(marker) }
|
|
165
179
|
end
|
|
166
180
|
|
|
167
181
|
def feed_looking_body?
|
|
168
|
-
snippet = body.to_s[0, 800].downcase
|
|
182
|
+
snippet = body.to_s.b[0, 800].downcase
|
|
169
183
|
FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) }
|
|
170
184
|
end
|
|
171
185
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestService
|
|
5
|
+
##
|
|
6
|
+
# Bounded, thread-safe TTL cache for initial page fetches; install with
|
|
7
|
+
# {RequestService.response_cache=}. Only successful (2xx) responses are stored, so a
|
|
8
|
+
# block page or rate limit is never replayed. Hits are returned tagged with their
|
|
9
|
+
# {Response#cache_age}; memory is capped in body bytes as well as entries.
|
|
10
|
+
class ResponseCache
|
|
11
|
+
# Default entry ceiling; oldest entries are evicted first.
|
|
12
|
+
DEFAULT_MAX_ENTRIES = 32
|
|
13
|
+
# Default body-byte budget across all entries.
|
|
14
|
+
DEFAULT_MAX_BYTES = 32 * 1024 * 1024
|
|
15
|
+
# Monotonic seconds source, immune to wall-clock jumps.
|
|
16
|
+
MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
17
|
+
Entry = Data.define(:stored_at, :response, :bytes)
|
|
18
|
+
private_constant :Entry
|
|
19
|
+
|
|
20
|
+
# @return [Integer] seconds an entry stays fresh
|
|
21
|
+
attr_reader :ttl
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @param ttl [Integer] seconds an entry stays fresh
|
|
25
|
+
# @param max_entries [Integer] maximum stored entries
|
|
26
|
+
# @param max_bytes [Integer] maximum summed body bytes
|
|
27
|
+
# @param clock [#call] monotonic seconds source (injectable for specs)
|
|
28
|
+
def initialize(ttl:, max_entries: DEFAULT_MAX_ENTRIES, max_bytes: DEFAULT_MAX_BYTES, clock: MONOTONIC_CLOCK)
|
|
29
|
+
@ttl = Option.integer(:ttl, ttl, min: 1)
|
|
30
|
+
@max_entries = Option.integer(:max_entries, max_entries, min: 1)
|
|
31
|
+
@max_bytes = Option.integer(:max_bytes, max_bytes, min: 1)
|
|
32
|
+
@clock = clock
|
|
33
|
+
@entries = {}
|
|
34
|
+
@bytes = 0
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@flight_done = ConditionVariable.new
|
|
37
|
+
@inflight = Set.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Concurrent callers missing the same key share one fetch: the first builds the response while
|
|
42
|
+
# the others wait, then each is served its own replica. A raising or uncacheable fetch is not
|
|
43
|
+
# stored; waiters then retry in turn so none is left blocked.
|
|
44
|
+
# @param key [Object] hashable cache key
|
|
45
|
+
# @yieldreturn [Response] the freshly fetched response, built only on a miss
|
|
46
|
+
# @return [Response] a cache-aged replica on a hit, otherwise the freshly built response
|
|
47
|
+
def fetch(key, &)
|
|
48
|
+
cached = claim(key)
|
|
49
|
+
return cached if cached
|
|
50
|
+
|
|
51
|
+
lead(key, &)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Returns a fresh replica, or nil after registering the caller as the key's fetcher.
|
|
57
|
+
def claim(key)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
loop do
|
|
60
|
+
hit = lookup(key)
|
|
61
|
+
break hit if hit
|
|
62
|
+
break @inflight.add(key) && nil unless @inflight.include?(key)
|
|
63
|
+
|
|
64
|
+
@flight_done.wait(@mutex)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def lead(key)
|
|
70
|
+
yield.tap { |response| store(key, response) if cacheable?(response) }
|
|
71
|
+
ensure
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
@inflight.delete(key)
|
|
74
|
+
@flight_done.broadcast
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Callers must hold @mutex.
|
|
79
|
+
def lookup(key)
|
|
80
|
+
entry = @entries[key]
|
|
81
|
+
age = entry && (@clock.call - entry.stored_at)
|
|
82
|
+
entry.response.replica(cache_age: age.floor) if age && age < @ttl
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def store(key, response)
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
evict(key)
|
|
88
|
+
bytes = response.body.bytesize
|
|
89
|
+
@entries[key] = Entry.new(stored_at: @clock.call, response:, bytes:)
|
|
90
|
+
@bytes += bytes
|
|
91
|
+
evict_oldest while @entries.size > @max_entries || @bytes > @max_bytes
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def evict(key)
|
|
96
|
+
@bytes -= @entries.delete(key)&.bytes.to_i
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def evict_oldest
|
|
100
|
+
_key, entry = @entries.shift
|
|
101
|
+
@bytes -= entry.bytes
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cacheable?(response)
|
|
105
|
+
(200..299).cover?(response.status) && response.body.bytesize <= @max_bytes
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class RequestService
|
|
7
|
+
##
|
|
8
|
+
# Opt-in reuse of initial page fetches, e.g. while a person iterates on selectors for one page.
|
|
9
|
+
# Extended onto {RequestService}; the cache is consulted only for +relation == :initial+ requests,
|
|
10
|
+
# follow-ups always hit the network.
|
|
11
|
+
#
|
|
12
|
+
# The key is a SHA256 digest of the URL, strategy, headers, request options and the policy fields
|
|
13
|
+
# that shape a response, so credentials never reach the cache and a response fetched under a
|
|
14
|
+
# permissive {Policy} is never served to a stricter one. Cookie-store state is not part of the key.
|
|
15
|
+
module ResponseCaching
|
|
16
|
+
# Mutex guarding the process-wide response cache reference.
|
|
17
|
+
RESPONSE_CACHE_MUTEX = Mutex.new
|
|
18
|
+
# Fiber-storage key for the active {#observe_cache_hits} collector.
|
|
19
|
+
CACHE_HITS_KEY = :html2rss_cache_hits
|
|
20
|
+
# Fiber-storage key for the cache installed by {#with_response_cache}.
|
|
21
|
+
SCOPED_CACHE_KEY = :html2rss_scoped_response_cache
|
|
22
|
+
private_constant :RESPONSE_CACHE_MUTEX, :CACHE_HITS_KEY, :SCOPED_CACHE_KEY
|
|
23
|
+
|
|
24
|
+
# @param cache [#fetch, nil] receives +fetch(key) { response }+ and returns the cached or freshly
|
|
25
|
+
# built {Response} (tagged with {Response#cache_age} on a hit), e.g. {ResponseCache};
|
|
26
|
+
# +nil+ (the default) disables caching. A raising block must not be cached.
|
|
27
|
+
# @return [void]
|
|
28
|
+
def response_cache=(cache)
|
|
29
|
+
RESPONSE_CACHE_MUTEX.synchronize { @response_cache = cache }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [#fetch, nil] the scoped cache from {#with_response_cache} if one is active, otherwise
|
|
33
|
+
# the globally configured initial-response cache, if any
|
|
34
|
+
def response_cache
|
|
35
|
+
scoped = Fiber[SCOPED_CACHE_KEY]
|
|
36
|
+
return scoped.first if scoped
|
|
37
|
+
|
|
38
|
+
RESPONSE_CACHE_MUTEX.synchronize { @response_cache }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Runs the block with +cache+ as the initial-response cache for the current fiber (and fibers or
|
|
42
|
+
# threads it spawns), taking precedence over {#response_cache=}. The previous scope is restored
|
|
43
|
+
# when the block exits, even by raising.
|
|
44
|
+
#
|
|
45
|
+
# @param cache [#fetch, nil] cache for the block; +nil+ disables caching inside it
|
|
46
|
+
# @yieldreturn [Object] the block's result
|
|
47
|
+
# @return [Object] the block's result
|
|
48
|
+
def with_response_cache(cache)
|
|
49
|
+
previous = Fiber[SCOPED_CACHE_KEY]
|
|
50
|
+
Fiber[SCOPED_CACHE_KEY] = [cache]
|
|
51
|
+
yield
|
|
52
|
+
ensure
|
|
53
|
+
Fiber[SCOPED_CACHE_KEY] = previous
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runs the block and reports the cache ages of every initial fetch it served from {#response_cache},
|
|
57
|
+
# including fetches on threads the block spawns (fiber storage is inherited by reference).
|
|
58
|
+
#
|
|
59
|
+
# @yieldreturn [Object] the block's result
|
|
60
|
+
# @return [Array(Object, Array<Integer>)] the block's result and the served cache ages in seconds
|
|
61
|
+
def observe_cache_hits
|
|
62
|
+
hits = Fiber[CACHE_HITS_KEY] = Queue.new
|
|
63
|
+
result = yield
|
|
64
|
+
[result, Array.new(hits.size) { hits.pop }]
|
|
65
|
+
ensure
|
|
66
|
+
Fiber[CACHE_HITS_KEY] = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# Runs +fetch+ directly, or through {#response_cache} for initial requests.
|
|
73
|
+
# Callers always receive their own {Response} so parsed documents are never shared.
|
|
74
|
+
# @param ctx [Context] the request context
|
|
75
|
+
# @param strategy [Symbol] the resolved strategy name
|
|
76
|
+
# @yieldreturn [Response] the freshly fetched response
|
|
77
|
+
# @return [Response]
|
|
78
|
+
def fetch_initial_through_cache(ctx, strategy, &)
|
|
79
|
+
cache = response_cache
|
|
80
|
+
return yield unless cache && ctx.relation == :initial
|
|
81
|
+
|
|
82
|
+
cache.fetch(response_cache_key(ctx, strategy), &).replica.tap { |response| record_cache_hit(response) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def record_cache_hit(response)
|
|
86
|
+
Fiber[CACHE_HITS_KEY]&.push(response.cache_age) if response.cache_age
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def response_cache_key(ctx, strategy)
|
|
90
|
+
parts = [ctx.url.to_s, strategy, ctx.headers.sort, ctx.request, ctx.policy.cache_key]
|
|
91
|
+
Digest::SHA256.hexdigest(parts.inspect)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -18,12 +18,13 @@ module Html2rss
|
|
|
18
18
|
# Executes the request through the shared preflight/postflight path.
|
|
19
19
|
#
|
|
20
20
|
# @return [Response] the response from the strategy
|
|
21
|
-
# @raise [NotImplementedError] if the subclass does not implement `#fetch`
|
|
22
21
|
def execute
|
|
23
22
|
check_timeout!
|
|
24
23
|
response = perform_execute
|
|
25
24
|
postflight!(response)
|
|
26
25
|
response
|
|
26
|
+
rescue Html2rss::ContractError
|
|
27
|
+
raise
|
|
27
28
|
rescue StandardError => error
|
|
28
29
|
handle_error(error)
|
|
29
30
|
end
|
|
@@ -42,15 +43,6 @@ module Html2rss
|
|
|
42
43
|
fetch
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
##
|
|
46
|
-
# Adapter hook: perform the transport-specific fetch after preflight.
|
|
47
|
-
#
|
|
48
|
-
# @return [Response] normalized response
|
|
49
|
-
# @raise [NotImplementedError] when a subclass omits the hook
|
|
50
|
-
def fetch
|
|
51
|
-
raise NotImplementedError, 'Subclass must implement #fetch'
|
|
52
|
-
end
|
|
53
|
-
|
|
54
46
|
##
|
|
55
47
|
# Consumes a request slot and validates the destination before transport work.
|
|
56
48
|
#
|
|
@@ -60,7 +52,9 @@ module Html2rss
|
|
|
60
52
|
return if skip_preflight?
|
|
61
53
|
|
|
62
54
|
ctx.budget.consume! if consume_budget
|
|
63
|
-
ctx.policy.validate_request!(
|
|
55
|
+
ctx.policy.validate_request!(
|
|
56
|
+
url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation, advertisement: ctx.advertisement
|
|
57
|
+
)
|
|
64
58
|
end
|
|
65
59
|
|
|
66
60
|
##
|