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
|
@@ -7,56 +7,58 @@ require 'zlib'
|
|
|
7
7
|
module Html2rss
|
|
8
8
|
class RequestService
|
|
9
9
|
# Decodes gzip/brotli bodies when Content-Encoding is missing or the type is octet-stream.
|
|
10
|
-
|
|
10
|
+
class CompressedBody
|
|
11
11
|
# Gzip stream magic (RFC 1952).
|
|
12
12
|
GZIP_MAGIC = "\x1F\x8B".b.freeze
|
|
13
13
|
# HTML prefix for unlabeled brotli trial decode. Same object as {Response::HTML_BODY_SNIFF}.
|
|
14
14
|
HTML_BODY_SNIFF = Response::HTML_BODY_SNIFF
|
|
15
15
|
|
|
16
|
-
module_function
|
|
17
|
-
|
|
18
16
|
# @param body [String, nil] raw response body
|
|
19
17
|
# @param headers [Hash] response headers (Content-Encoding / Content-Type)
|
|
20
18
|
# @return [String, nil] decoded body when compressed; otherwise the original body
|
|
21
|
-
def decode(body, headers: {})
|
|
22
|
-
return body if body.nil? || body.empty?
|
|
23
|
-
|
|
19
|
+
def self.decode(body, headers: {})
|
|
24
20
|
raw = body.to_s.b
|
|
25
|
-
|
|
21
|
+
new(raw, headers).decoded_body || body
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param raw [String] binary response body
|
|
25
|
+
# @param headers [Hash] response headers (Content-Encoding / Content-Type)
|
|
26
|
+
def initialize(raw, headers)
|
|
27
|
+
@raw = raw
|
|
28
|
+
@headers = headers
|
|
26
29
|
end
|
|
30
|
+
private_class_method :new
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
# @return [String, nil] inflated body, or nil when the bytes are not a recognized stream
|
|
33
|
+
def decoded_body
|
|
34
|
+
decode_labeled || decode_unlabeled
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def decode_labeled
|
|
40
|
+
encodings = content_encodings
|
|
30
41
|
return if encodings.empty?
|
|
31
42
|
|
|
32
|
-
encodings.reverse.reduce(raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
|
|
43
|
+
encodings.reverse.reduce(@raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
|
|
33
44
|
end
|
|
34
|
-
module_function :decode_labeled
|
|
35
|
-
private_class_method :decode_labeled
|
|
36
45
|
|
|
37
|
-
def decode_unlabeled
|
|
38
|
-
return uncompress_gzip(raw) if raw.start_with?(GZIP_MAGIC)
|
|
39
|
-
return unless octet_stream?
|
|
46
|
+
def decode_unlabeled
|
|
47
|
+
return uncompress_gzip(@raw) if @raw.start_with?(GZIP_MAGIC)
|
|
48
|
+
return unless octet_stream?
|
|
40
49
|
|
|
41
|
-
try_brotli(raw)
|
|
50
|
+
try_brotli(@raw)
|
|
42
51
|
end
|
|
43
|
-
module_function :decode_unlabeled
|
|
44
|
-
private_class_method :decode_unlabeled
|
|
45
52
|
|
|
46
53
|
def inflate(raw, encoding)
|
|
47
54
|
case encoding
|
|
48
|
-
when 'gzip'
|
|
49
|
-
return raw unless raw.start_with?(GZIP_MAGIC)
|
|
50
|
-
|
|
51
|
-
uncompress_gzip(raw)
|
|
55
|
+
when 'gzip' then uncompress_gzip(raw)
|
|
52
56
|
when 'br' then try_brotli(raw, require_html: false)
|
|
53
57
|
when 'deflate' then inflate_deflate(raw)
|
|
54
58
|
end
|
|
55
59
|
rescue Zlib::Error, Brotli::Error, ArgumentError
|
|
56
60
|
nil
|
|
57
61
|
end
|
|
58
|
-
module_function :inflate
|
|
59
|
-
private_class_method :inflate
|
|
60
62
|
|
|
61
63
|
def inflate_deflate(raw)
|
|
62
64
|
inflater = nil
|
|
@@ -67,51 +69,37 @@ module Html2rss
|
|
|
67
69
|
ensure
|
|
68
70
|
inflater&.close
|
|
69
71
|
end
|
|
70
|
-
module_function :inflate_deflate
|
|
71
|
-
private_class_method :inflate_deflate
|
|
72
72
|
|
|
73
73
|
def uncompress_gzip(raw)
|
|
74
|
-
Zlib::GzipReader.wrap(StringIO.new(raw),
|
|
74
|
+
Zlib::GzipReader.wrap(StringIO.new(raw), &:read).b
|
|
75
75
|
rescue Zlib::Error
|
|
76
76
|
nil
|
|
77
77
|
end
|
|
78
|
-
module_function :uncompress_gzip
|
|
79
|
-
private_class_method :uncompress_gzip
|
|
80
78
|
|
|
81
79
|
def try_brotli(raw, require_html: true)
|
|
82
80
|
inflated = Brotli.inflate(raw)
|
|
83
81
|
return inflated unless require_html
|
|
84
|
-
return inflated if inflated.b.match?(HTML_BODY_SNIFF)
|
|
85
82
|
|
|
86
|
-
nil
|
|
83
|
+
inflated.b.match?(HTML_BODY_SNIFF) ? inflated : nil
|
|
87
84
|
rescue Brotli::Error, ArgumentError
|
|
88
85
|
nil
|
|
89
86
|
end
|
|
90
|
-
module_function :try_brotli
|
|
91
|
-
private_class_method :try_brotli
|
|
92
87
|
|
|
93
|
-
def content_encodings
|
|
94
|
-
header(
|
|
88
|
+
def content_encodings
|
|
89
|
+
header('content-encoding')
|
|
95
90
|
.to_s
|
|
96
91
|
.split(',')
|
|
97
|
-
.
|
|
92
|
+
.map { |val| val.strip.downcase }
|
|
93
|
+
.reject(&:empty?)
|
|
98
94
|
end
|
|
99
|
-
module_function :content_encodings
|
|
100
|
-
private_class_method :content_encodings
|
|
101
95
|
|
|
102
|
-
def octet_stream?
|
|
103
|
-
header(
|
|
96
|
+
def octet_stream?
|
|
97
|
+
header('content-type').to_s.downcase.include?('application/octet-stream')
|
|
104
98
|
end
|
|
105
|
-
module_function :octet_stream?
|
|
106
|
-
private_class_method :octet_stream?
|
|
107
99
|
|
|
108
|
-
def header(
|
|
109
|
-
headers.
|
|
110
|
-
headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
|
|
111
|
-
end
|
|
100
|
+
def header(name)
|
|
101
|
+
@headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
|
|
112
102
|
end
|
|
113
|
-
module_function :header
|
|
114
|
-
private_class_method :header
|
|
115
103
|
end
|
|
116
104
|
end
|
|
117
105
|
end
|
|
@@ -6,6 +6,9 @@ module Html2rss
|
|
|
6
6
|
# Holds information needed to send requests to websites.
|
|
7
7
|
# To be passed down to the RequestService's strategies.
|
|
8
8
|
class Context
|
|
9
|
+
# Raised when a context is built without its runtime policy or request budget.
|
|
10
|
+
class InvalidError < Html2rss::ContractError; end
|
|
11
|
+
|
|
9
12
|
##
|
|
10
13
|
# @param url [String, Html2rss::Url] the URL to request
|
|
11
14
|
# @param headers [Hash] HTTP request headers
|
|
@@ -15,7 +18,8 @@ module Html2rss
|
|
|
15
18
|
# @option request_options [String, Html2rss::Url, nil] :origin_url originating URL for same-origin checks
|
|
16
19
|
# @option request_options [Policy] :policy runtime request policy
|
|
17
20
|
# @option request_options [Budget] :budget shared request budget for the feed build
|
|
18
|
-
# @
|
|
21
|
+
# @option request_options [FeedAdvertisement, nil] :advertisement page proof for an +:advertised_feed+ follow-up
|
|
22
|
+
# @raise [InvalidError] if policy or budget is nil or budget is missing
|
|
19
23
|
def initialize(url:, headers: {}, request: {}, **request_options)
|
|
20
24
|
@url = Html2rss::Url.from_absolute(url)
|
|
21
25
|
@headers = normalize_headers(headers).freeze
|
|
@@ -44,23 +48,19 @@ module Html2rss
|
|
|
44
48
|
# @return [Budget] the shared request budget
|
|
45
49
|
attr_reader :budget
|
|
46
50
|
|
|
51
|
+
# @return [FeedAdvertisement, nil] the page's advertised feeds, for an +:advertised_feed+ follow-up
|
|
52
|
+
attr_reader :advertisement
|
|
53
|
+
|
|
47
54
|
##
|
|
48
55
|
# Builds a follow-up request context sharing headers, budget, and policy.
|
|
49
56
|
#
|
|
50
57
|
# @param url [String, Html2rss::Url] the follow-up URL
|
|
51
58
|
# @param relation [Symbol] why the follow-up is being made
|
|
52
59
|
# @param origin_url [String, Html2rss::Url] effective origin for same-origin checks
|
|
60
|
+
# @param advertisement [FeedAdvertisement, nil] page proof for an +:advertised_feed+ follow-up
|
|
53
61
|
# @return [Context] derived request context
|
|
54
|
-
def follow_up(url:, relation:, origin_url: self.origin_url)
|
|
55
|
-
self.class.new(
|
|
56
|
-
url:,
|
|
57
|
-
headers:,
|
|
58
|
-
request:,
|
|
59
|
-
relation:,
|
|
60
|
-
origin_url:,
|
|
61
|
-
policy:,
|
|
62
|
-
budget:
|
|
63
|
-
)
|
|
62
|
+
def follow_up(url:, relation:, origin_url: self.origin_url, advertisement: nil)
|
|
63
|
+
self.class.new(url:, headers:, request:, relation:, origin_url:, policy:, budget:, advertisement:)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
private
|
|
@@ -68,11 +68,12 @@ module Html2rss
|
|
|
68
68
|
def assign_request_options(request_options)
|
|
69
69
|
@relation = request_options.fetch(:relation, :initial)
|
|
70
70
|
@policy = request_options.fetch(:policy, Policy.default)
|
|
71
|
-
raise
|
|
71
|
+
raise InvalidError, 'policy must not be nil' if @policy.nil?
|
|
72
72
|
|
|
73
73
|
@origin_url = normalized_origin_url(request_options[:origin_url])
|
|
74
|
-
@
|
|
75
|
-
raise
|
|
74
|
+
@advertisement = request_options[:advertisement]
|
|
75
|
+
@budget = request_options.fetch(:budget) { raise InvalidError, 'budget is required' }
|
|
76
|
+
raise InvalidError, 'budget must not be nil' if @budget.nil?
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
def normalized_origin_url(origin_url)
|
|
@@ -87,9 +88,7 @@ module Html2rss
|
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
def normalize_request(request)
|
|
90
|
-
|
|
91
|
-
HashUtil.assert_symbol_keys!(normalized, context: 'request')
|
|
92
|
-
normalized
|
|
91
|
+
HashUtil.deep_symbolize_keys(request, context: 'request')
|
|
93
92
|
end
|
|
94
93
|
end
|
|
95
94
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestService
|
|
5
|
+
##
|
|
6
|
+
# One host/egress-scoped view onto the {CookieStore}: replay, refresh and evict with count-only telemetry.
|
|
7
|
+
# Cookie names and values are session credentials and never reach logs.
|
|
8
|
+
class CookieJar
|
|
9
|
+
# Share of replayable requests that deliberately skip replay, so the pass-rate effect stays measurable.
|
|
10
|
+
HOLDOUT_RATE = Float(ENV.fetch('BOTASAURUS_COOKIE_HOLDOUT_RATE', 0.1))
|
|
11
|
+
# Shared across jars so blocks accumulate per key within the process.
|
|
12
|
+
BREAKER = ReplayBreaker.new
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @param host [String] target host
|
|
16
|
+
# @param scraper_base_url [String] scrape-api base URL
|
|
17
|
+
# @param proxy [String, nil] egress proxy
|
|
18
|
+
# @param user_agent [String, nil] browser user agent
|
|
19
|
+
# @param store [#fetch, #write, #evict] the configured cookie store
|
|
20
|
+
def initialize(host:, scraper_base_url:, proxy:, user_agent:, store: RequestService.cookie_store)
|
|
21
|
+
@store = store
|
|
22
|
+
@host = host
|
|
23
|
+
@key = CookieStore.key(host:, scraper_base_url:, proxy:, user_agent:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash{String => String}] live stored cookies ({} when none)
|
|
27
|
+
def stored = store.fetch(key)
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @param stored [Hash{String => String}] cookies in the jar
|
|
31
|
+
# @return [Boolean] whether to replay them (not when empty, the breaker is open, or in the holdout)
|
|
32
|
+
def replayable?(stored)
|
|
33
|
+
return false if stored.empty?
|
|
34
|
+
|
|
35
|
+
reason = skip_reason
|
|
36
|
+
log(reason, 0) if reason
|
|
37
|
+
reason.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Records what a replay led to: a pass resets the breaker; a block evicts the jar and counts toward it.
|
|
42
|
+
#
|
|
43
|
+
# @param outcome [Symbol] +:pass+ or +:block+
|
|
44
|
+
# @param stored [Hash{String => String}] cookies that were replayed
|
|
45
|
+
# @return [void]
|
|
46
|
+
def replayed(outcome, stored)
|
|
47
|
+
log("replay_#{outcome}", stored.size)
|
|
48
|
+
return BREAKER.record_pass(key) if outcome == :pass
|
|
49
|
+
|
|
50
|
+
evict(stored)
|
|
51
|
+
log('breaker_open', 0) if BREAKER.record_block(key, ttl: CookieStore::DEFAULT_TTL_SECONDS)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# @param stored [Hash{String => String}] cookies that were replayed
|
|
56
|
+
# @param earned [Hash{String => String}] cookies returned by the scrape
|
|
57
|
+
# @return [void]
|
|
58
|
+
def refresh(stored, earned)
|
|
59
|
+
ttl = CookieStore::DEFAULT_TTL_SECONDS
|
|
60
|
+
return if earned.empty? || ttl <= 0
|
|
61
|
+
|
|
62
|
+
store.write(key, stored.merge(earned), ttl:)
|
|
63
|
+
log('refresh', earned.size)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# @param stored [Hash{String => String}] cookies that were rejected
|
|
68
|
+
# @return [void]
|
|
69
|
+
def evict(stored)
|
|
70
|
+
store.evict(key)
|
|
71
|
+
log('evict', stored.size)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
attr_reader :store, :key, :host
|
|
77
|
+
|
|
78
|
+
def skip_reason
|
|
79
|
+
return 'breaker_open' if BREAKER.open?(key)
|
|
80
|
+
|
|
81
|
+
'holdout' if rand < HOLDOUT_RATE
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def log(action, count)
|
|
85
|
+
Log.info({ event_name: 'botasaurus.cookie_jar', action:, host:, cookie_count: count }.to_json)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class RequestService
|
|
7
|
+
##
|
|
8
|
+
# In-process cookie jar for anti-bot clearance cookies returned by botasaurus-scrape-api.
|
|
9
|
+
#
|
|
10
|
+
# The scrape API is stateless, so html2rss owns reuse. Any object responding to
|
|
11
|
+
# +fetch(key)+, +write(key, cookies, ttl:)+ and +evict(key)+ can replace this store via
|
|
12
|
+
# {RequestService.cookie_store=} (e.g. Redis or a database in html2rss-web).
|
|
13
|
+
#
|
|
14
|
+
# Values are session credentials: never log, dump or persist them in feed config.
|
|
15
|
+
class CookieStore
|
|
16
|
+
# Seconds stored cookies are replayed (the API returns no expiry; 0 disables reuse).
|
|
17
|
+
DEFAULT_TTL_SECONDS = Integer(ENV.fetch('BOTASAURUS_COOKIE_TTL_SECONDS', 1800))
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# Builds a jar key from everything a clearance cookie is bound to.
|
|
21
|
+
# The digest keeps proxy credentials out of keys, logs and external stores.
|
|
22
|
+
#
|
|
23
|
+
# @param host [String] target host
|
|
24
|
+
# @param scraper_base_url [String] scrape-api base URL
|
|
25
|
+
# @param proxy [String, nil] egress proxy
|
|
26
|
+
# @param user_agent [String, nil] browser user agent
|
|
27
|
+
# @return [String] opaque jar key
|
|
28
|
+
def self.key(host:, scraper_base_url:, proxy:, user_agent:)
|
|
29
|
+
Digest::SHA256.hexdigest([host, scraper_base_url, proxy, user_agent].join("\0"))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@entries = {}
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @param key [String] jar key
|
|
39
|
+
# @return [Hash{String => String}] live cookies, {} when absent or expired
|
|
40
|
+
def fetch(key)
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
cookies, expires_at = @entries[key]
|
|
43
|
+
next {} if cookies.nil?
|
|
44
|
+
next cookies.dup if now < expires_at
|
|
45
|
+
|
|
46
|
+
@entries.delete(key)
|
|
47
|
+
{}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# @param key [String] jar key
|
|
53
|
+
# @param cookies [Hash{String => String}] cookies to keep
|
|
54
|
+
# @param ttl [Numeric] seconds until expiry
|
|
55
|
+
# @return [void]
|
|
56
|
+
def write(key, cookies, ttl: DEFAULT_TTL_SECONDS)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
purge_expired
|
|
59
|
+
@entries[key] = [cookies.dup.freeze, now + ttl]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# @param key [String] jar key
|
|
65
|
+
# @return [void]
|
|
66
|
+
def evict(key)
|
|
67
|
+
@mutex.synchronize { @entries.delete(key) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def purge_expired
|
|
73
|
+
@entries.delete_if { |_key, (_cookies, expires_at)| expires_at <= now }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestService
|
|
5
|
+
##
|
|
6
|
+
# Proof that a page advertises feed URLs in its head (+link[rel=alternate]+ with an
|
|
7
|
+
# RSS/Atom/RDF type and an +http(s)+ href, see {Html::FeedLink}).
|
|
8
|
+
#
|
|
9
|
+
# Built only from a parsed document ({.from_document}), so a caller cannot mint permission
|
|
10
|
+
# for an arbitrary URL: +new+ and +allocate+ are private, an instance and its +feeds+ are
|
|
11
|
+
# frozen, and +dup+/+clone+ return the instance itself (there is no +with+ copy).
|
|
12
|
+
# Reflection (+send(:new)+, +instance_variable_set+ on an allocated object, +Marshal.load+)
|
|
13
|
+
# still can, as nothing in-process can stop it; {Policy} also rejects an unfrozen proof.
|
|
14
|
+
#
|
|
15
|
+
# {Policy} lets an +:advertised_feed+ follow-up leave the page origin only for a URL listed
|
|
16
|
+
# here. A feed off the page origin is recorded as +https+ (an advertised +http:+ feed is
|
|
17
|
+
# fetched as +https:+ or not at all).
|
|
18
|
+
#
|
|
19
|
+
# +feeds+ maps each advertised feed URL to its link title, in document order.
|
|
20
|
+
class FeedAdvertisement
|
|
21
|
+
private_class_method :new, :allocate
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @param doc [Nokogiri::HTML::Document] parsed page
|
|
25
|
+
# @param page_url [String, Html2rss::Url] URL the page was fetched from
|
|
26
|
+
# @return [FeedAdvertisement] the page's advertised feeds (possibly none)
|
|
27
|
+
def self.from_document(doc, page_url:)
|
|
28
|
+
page = Url.from_absolute(page_url)
|
|
29
|
+
feeds = Html::FeedLink.from_document(doc).each_with_object({}) do |link, acc|
|
|
30
|
+
url = feed_url(link.href, page)
|
|
31
|
+
acc[url] ||= link.title if url
|
|
32
|
+
end
|
|
33
|
+
new(page_url: page, feeds: feeds.freeze)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.feed_url(href, page)
|
|
37
|
+
return unless (url = Url.resolve_web(href, page))
|
|
38
|
+
|
|
39
|
+
Policy.same_origin?(url, page) ? url : url.with_scheme('https')
|
|
40
|
+
end
|
|
41
|
+
private_class_method :feed_url
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @return [Html2rss::Url] the page the feeds were read from
|
|
45
|
+
attr_reader :page_url
|
|
46
|
+
|
|
47
|
+
# @return [Hash{Html2rss::Url => String, nil}] advertised feed URL => link title, in document order
|
|
48
|
+
attr_reader :feeds
|
|
49
|
+
|
|
50
|
+
# @param page_url [Html2rss::Url]
|
|
51
|
+
# @param feeds [Hash{Html2rss::Url => String, nil}] frozen
|
|
52
|
+
def initialize(page_url:, feeds:)
|
|
53
|
+
@page_url = page_url
|
|
54
|
+
@feeds = feeds
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# @param url [Html2rss::Url]
|
|
60
|
+
# @return [Boolean] whether the page advertises +url+
|
|
61
|
+
def advertises?(url) = feeds.key?(url)
|
|
62
|
+
|
|
63
|
+
# @return [FeedAdvertisement] itself: a proof is immutable, so no copy is ever minted
|
|
64
|
+
def dup = self
|
|
65
|
+
|
|
66
|
+
# The proof stays frozen whatever +freeze:+ asks.
|
|
67
|
+
#
|
|
68
|
+
# @return [FeedAdvertisement] itself
|
|
69
|
+
def clone(**) = self
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -154,7 +154,7 @@ module Html2rss
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def begin_terminal_url_request!(terminal_url)
|
|
157
|
-
|
|
157
|
+
validate_redirect!(terminal_url)
|
|
158
158
|
@request_url_override = terminal_url
|
|
159
159
|
@last_redirect_to = nil
|
|
160
160
|
@current_url = terminal_url
|
|
@@ -213,14 +213,18 @@ module Html2rss
|
|
|
213
213
|
def redirect_callback
|
|
214
214
|
lambda do |redirect_uri|
|
|
215
215
|
to_url = normalize_url(redirect_uri)
|
|
216
|
-
from_url = @current_url
|
|
217
216
|
@last_redirect_to = to_url
|
|
218
|
-
|
|
217
|
+
validate_redirect!(to_url)
|
|
219
218
|
@current_url = to_url
|
|
220
219
|
true
|
|
221
220
|
end
|
|
222
221
|
end
|
|
223
222
|
|
|
223
|
+
def validate_redirect!(to_url)
|
|
224
|
+
ctx.policy.validate_redirect!(from_url: @current_url, to_url:, origin_url: ctx.origin_url,
|
|
225
|
+
relation: ctx.relation, advertisement: ctx.advertisement)
|
|
226
|
+
end
|
|
227
|
+
|
|
224
228
|
def build_response(response)
|
|
225
229
|
headers = response.headers.to_h
|
|
226
230
|
Response.new(
|
|
@@ -20,12 +20,11 @@ module Html2rss
|
|
|
20
20
|
|
|
21
21
|
##
|
|
22
22
|
# @return [Response] the mock response wrapped around the file contents
|
|
23
|
-
# @raise [
|
|
23
|
+
# @raise [InvalidOption] if the local file path is missing
|
|
24
24
|
# @raise [Errno::ENOENT] if the file does not exist
|
|
25
25
|
def fetch
|
|
26
26
|
file_path = ctx.request[:local_file_path]
|
|
27
|
-
raise
|
|
28
|
-
raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
|
|
27
|
+
raise InvalidOption, 'Local file path is required for local_file strategy' unless file_path
|
|
29
28
|
|
|
30
29
|
body = File.read(file_path)
|
|
31
30
|
Response.new(
|
|
@@ -68,7 +68,7 @@ module Html2rss
|
|
|
68
68
|
def validate_remote_ip!(ip:, url:)
|
|
69
69
|
return if allow_private_networks?
|
|
70
70
|
|
|
71
|
-
parsed_ip = ip
|
|
71
|
+
parsed_ip = parse_ip(ip.to_s)
|
|
72
72
|
raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
|
|
73
73
|
return unless blocked_ip?(parsed_ip)
|
|
74
74
|
|
|
@@ -122,7 +122,8 @@ module Html2rss
|
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
def blocked_ip?(address)
|
|
125
|
-
|
|
125
|
+
native = address.native
|
|
126
|
+
BLOCKED_IP_RANGES.any? { |range| range.include?(native) }
|
|
126
127
|
end
|
|
127
128
|
end
|
|
128
129
|
end
|