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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Edge validation for caller-supplied option values (public API, CLI, MCP).
|
|
6
|
+
# Everything past these checks may trust its types.
|
|
7
|
+
module Option
|
|
8
|
+
class << self
|
|
9
|
+
##
|
|
10
|
+
# @param name [Symbol] option name used in the error message
|
|
11
|
+
# @param value [Object] candidate value
|
|
12
|
+
# @param min [Integer] inclusive lower bound
|
|
13
|
+
# @param max [Integer, nil] inclusive upper bound
|
|
14
|
+
# @param optional [Boolean] whether +nil+ passes through
|
|
15
|
+
# @return [Integer, nil] +value+ when valid
|
|
16
|
+
# @raise [InvalidOption] when +value+ is not an Integer within bounds
|
|
17
|
+
def integer(name, value, min:, max: nil, optional: false)
|
|
18
|
+
return value if value.nil? && optional
|
|
19
|
+
return value if value.is_a?(Integer) && (min..max).cover?(value)
|
|
20
|
+
|
|
21
|
+
raise InvalidOption, "#{name} must be an Integer in #{min}..#{max}, got #{value.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# @param value [Symbol, String, nil] strategy plan name (+nil+ means +:auto+)
|
|
26
|
+
# @return [Symbol] the plan name
|
|
27
|
+
# @raise [InvalidOption] when +value+ is not a registered plan
|
|
28
|
+
def strategy(value)
|
|
29
|
+
name = value.nil? ? FeedPipeline::StrategyPlan::AUTO_NAME : value
|
|
30
|
+
return name.to_sym if FeedPipeline::StrategyPlan.valid?(name)
|
|
31
|
+
|
|
32
|
+
accepted = FeedPipeline::StrategyPlan.accepted_names.join(', ')
|
|
33
|
+
raise InvalidOption, "strategy must be one of #{accepted}, got #{value.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @param name [Symbol] option name used in the error message
|
|
38
|
+
# @param value [Object] candidate list
|
|
39
|
+
# @return [Array] +value+ when it is a non-empty Array
|
|
40
|
+
# @raise [InvalidOption] otherwise
|
|
41
|
+
def list(name, value)
|
|
42
|
+
return value if value.is_a?(Array) && value.any?
|
|
43
|
+
|
|
44
|
+
raise InvalidOption, "#{name} must be a non-empty Array, got #{value.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
| `final_url` | URL after redirects (may differ from requested) |
|
|
11
11
|
| `status` | HTTP status of the **final** response |
|
|
12
12
|
| `scheme_downgrade` | `true` when HTTPS entry landed on HTTP |
|
|
13
|
-
| `alternate_feeds` | `rel=alternate` RSS/Atom links found in HTML |
|
|
13
|
+
| `alternate_feeds` | `rel=alternate` RSS/Atom/RDF links found in HTML |
|
|
14
14
|
| `surface_category` | AutoSource surface class (`high_entropy_surface`, `unsupported_surface`, …) |
|
|
15
15
|
| `articles_count` | Cheap AutoSource extract (limit 10) — diagnostic only, not ship quality |
|
|
16
16
|
| `strategy` | Concrete transport used (`inspect` maps `auto` → default (HTTPX)) |
|
|
@@ -34,14 +34,12 @@ module Html2rss
|
|
|
34
34
|
JS_SHELL_MIN_BODY_BYTES = 8_192
|
|
35
35
|
private_constant :JS_SHELL_MIN_BODY_BYTES
|
|
36
36
|
|
|
37
|
-
module_function
|
|
38
|
-
|
|
39
37
|
##
|
|
40
38
|
# @param url [String]
|
|
41
39
|
# @param strategy [String, Symbol]
|
|
42
40
|
# @param deep [Boolean] when true and strategy is auto, one Botasaurus hop if configured
|
|
43
41
|
# @return [Report]
|
|
44
|
-
def call(url:, strategy: :auto, deep: false)
|
|
42
|
+
def self.call(url:, strategy: :auto, deep: false)
|
|
45
43
|
probe = PageRecon.probe(url, strategy: resolve_inspect_strategy(strategy, deep:))
|
|
46
44
|
recon = probe.result
|
|
47
45
|
response = probe.response
|
|
@@ -56,10 +54,12 @@ module Html2rss
|
|
|
56
54
|
# @param strategy [Symbol, String]
|
|
57
55
|
# @param concurrency [Integer]
|
|
58
56
|
# @return [Array<Report>]
|
|
59
|
-
def batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
|
|
57
|
+
def self.batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
|
|
60
58
|
Batch.map(Array(urls), concurrency:) do |url|
|
|
61
59
|
call(url:, strategy:)
|
|
62
|
-
rescue
|
|
60
|
+
rescue Html2rss::ContractError
|
|
61
|
+
raise
|
|
62
|
+
rescue Html2rss::Error => error
|
|
63
63
|
error_report(url, error)
|
|
64
64
|
end
|
|
65
65
|
end
|
|
@@ -67,7 +67,7 @@ module Html2rss
|
|
|
67
67
|
##
|
|
68
68
|
# @param response [Html2rss::RequestService::Response]
|
|
69
69
|
# @return [Hash] redacted XHR capture diagnostics (no query strings)
|
|
70
|
-
def xhr_capture_info(response)
|
|
70
|
+
def self.xhr_capture_info(response)
|
|
71
71
|
captured = response.captured_responses
|
|
72
72
|
{
|
|
73
73
|
count: captured.size,
|
|
@@ -79,7 +79,7 @@ module Html2rss
|
|
|
79
79
|
##
|
|
80
80
|
# @param entry [Hash] captured response hash
|
|
81
81
|
# @return [String, nil] scheme+host+path only
|
|
82
|
-
def redacted_endpoint(entry)
|
|
82
|
+
def self.redacted_endpoint(entry)
|
|
83
83
|
raw = entry['url'] || entry[:url]
|
|
84
84
|
return unless raw
|
|
85
85
|
|
|
@@ -94,7 +94,7 @@ module Html2rss
|
|
|
94
94
|
##
|
|
95
95
|
# @param entry [Hash] captured response hash
|
|
96
96
|
# @return [Boolean]
|
|
97
|
-
def xhr_candidate_articles?(entry)
|
|
97
|
+
def self.xhr_candidate_articles?(entry)
|
|
98
98
|
body = entry['body'] || entry[:body]
|
|
99
99
|
return false unless body.is_a?(String)
|
|
100
100
|
|
|
@@ -107,7 +107,7 @@ module Html2rss
|
|
|
107
107
|
##
|
|
108
108
|
# @param parsed [Object] parsed response body
|
|
109
109
|
# @return [Array<String>, Hash]
|
|
110
|
-
def scraper_info(parsed)
|
|
110
|
+
def self.scraper_info(parsed)
|
|
111
111
|
return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
|
|
112
112
|
|
|
113
113
|
begin
|
data/lib/html2rss/page_recon.rb
CHANGED
|
@@ -158,8 +158,6 @@ module Html2rss
|
|
|
158
158
|
def self.discover_segments(sst, url)
|
|
159
159
|
link_resolver = Scoring::LinkResolver.new(url)
|
|
160
160
|
AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
|
|
161
|
-
rescue StandardError
|
|
162
|
-
[]
|
|
163
161
|
end
|
|
164
162
|
|
|
165
163
|
##
|
|
@@ -268,8 +266,6 @@ module Html2rss
|
|
|
268
266
|
|
|
269
267
|
def sst_document
|
|
270
268
|
Html2rss::SST::Normalizer.call(response.body)
|
|
271
|
-
rescue ArgumentError
|
|
272
|
-
nil
|
|
273
269
|
end
|
|
274
270
|
|
|
275
271
|
def segment_stats(sst, page_url)
|
data/lib/html2rss/recon.rb
CHANGED
|
@@ -7,10 +7,8 @@ require 'fileutils'
|
|
|
7
7
|
module Html2rss
|
|
8
8
|
##
|
|
9
9
|
# Service that runs reconnaissance on a URL to discover redirect chains,
|
|
10
|
-
# native RSS/Atom feeds, surface categorization, and emit an actionable verdict.
|
|
10
|
+
# native RSS/Atom/RDF feeds, surface categorization, and emit an actionable verdict.
|
|
11
11
|
module Recon # rubocop:disable Metrics/ModuleLength
|
|
12
|
-
module_function
|
|
13
|
-
|
|
14
12
|
Verdict = Data.define(:name)
|
|
15
13
|
|
|
16
14
|
##
|
|
@@ -23,7 +21,7 @@ module Html2rss
|
|
|
23
21
|
##
|
|
24
22
|
# @param name [Symbol, String]
|
|
25
23
|
# @return [Verdict]
|
|
26
|
-
def self.[](name) = ALL.fetch(name.to_sym) { raise
|
|
24
|
+
def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown verdict: #{name.inspect}" }
|
|
27
25
|
|
|
28
26
|
##
|
|
29
27
|
# @return [Boolean]
|
|
@@ -106,19 +104,21 @@ module Html2rss
|
|
|
106
104
|
# @option options [Integer, nil] :max_redirects optional maximum redirects
|
|
107
105
|
# @option options [Integer, nil] :max_requests optional request budget
|
|
108
106
|
# @return [Html2rss::Recon::Result]
|
|
109
|
-
def call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
107
|
+
def self.call(url, strategy: :auto, cache_dir: nil, cache_mutex: nil, **) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
110
108
|
url_obj = Url.from_absolute(url)
|
|
111
109
|
|
|
112
110
|
begin
|
|
113
111
|
probe = PageRecon.probe(url_obj, strategy:, **)
|
|
114
|
-
rescue
|
|
112
|
+
rescue Html2rss::ContractError
|
|
113
|
+
raise
|
|
114
|
+
rescue Html2rss::Error => error
|
|
115
115
|
return error_result(url_obj, error)
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
cache_html_body(probe.response.body, url_obj, cache_dir, cache_mutex) if cache_dir && probe.response.body
|
|
119
119
|
|
|
120
120
|
recon = probe.result
|
|
121
|
-
native_feed, discovery_note = find_native_feed(
|
|
121
|
+
native_feed, discovery_note = find_native_feed(probe.session, probe.response)
|
|
122
122
|
notes = build_notes(recon, native_feed, probe.response)
|
|
123
123
|
notes << discovery_note if discovery_note
|
|
124
124
|
verdict = determine_verdict(recon, native_feed)
|
|
@@ -149,7 +149,7 @@ module Html2rss
|
|
|
149
149
|
# @option options [Integer, nil] :max_redirects optional maximum redirects
|
|
150
150
|
# @option options [Integer, nil] :max_requests optional request budget
|
|
151
151
|
# @return [Array<Html2rss::Recon::Result>]
|
|
152
|
-
def batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
|
|
152
|
+
def self.batch(urls, strategy: :auto, cache_dir: nil, max_threads: 5, **options)
|
|
153
153
|
url_list = urls.map { |u| u.to_s.strip }.reject(&:empty?)
|
|
154
154
|
return [] if url_list.empty?
|
|
155
155
|
|
|
@@ -162,24 +162,20 @@ module Html2rss
|
|
|
162
162
|
end
|
|
163
163
|
|
|
164
164
|
##
|
|
165
|
-
# @param url_obj [Html2rss::Url]
|
|
166
165
|
# @param session [Html2rss::RequestSession]
|
|
167
|
-
# @param response [Html2rss::RequestService::Response]
|
|
166
|
+
# @param response [Html2rss::RequestService::Response] the fetched page, where any redirect landed
|
|
168
167
|
# @return [Array(Html2rss::Url, nil), String, nil)] feed URL and optional discovery error note
|
|
169
|
-
def find_native_feed(
|
|
170
|
-
feed = Syndication::Discovery.
|
|
171
|
-
page_url: url_obj,
|
|
172
|
-
request_session: session,
|
|
173
|
-
parsed_body: (response.parsed_body if response.html_response?),
|
|
174
|
-
html: response.body
|
|
175
|
-
)
|
|
168
|
+
def self.find_native_feed(session, response)
|
|
169
|
+
feed = Syndication::Discovery.native_feed_url(response:, request_session: session)
|
|
176
170
|
[feed, nil]
|
|
177
|
-
rescue
|
|
171
|
+
rescue Html2rss::ContractError
|
|
172
|
+
raise
|
|
173
|
+
rescue Html2rss::Error => error
|
|
178
174
|
[nil, "discovery_error=#{error.class}: #{error.message}"]
|
|
179
175
|
end
|
|
180
176
|
private_class_method :find_native_feed
|
|
181
177
|
|
|
182
|
-
def build_notes(recon, native_feed, response)
|
|
178
|
+
def self.build_notes(recon, native_feed, response)
|
|
183
179
|
notes = []
|
|
184
180
|
notes << "native_rss=#{native_feed}" if native_feed
|
|
185
181
|
if recon.scheme_downgrade
|
|
@@ -192,7 +188,7 @@ module Html2rss
|
|
|
192
188
|
end
|
|
193
189
|
private_class_method :build_notes
|
|
194
190
|
|
|
195
|
-
def determine_verdict(recon, native_feed)
|
|
191
|
+
def self.determine_verdict(recon, native_feed)
|
|
196
192
|
return Verdict[:drop] if recon.status.nil? || recon.status >= 400 || recon.scheme_downgrade
|
|
197
193
|
return Verdict[:defer] if native_feed
|
|
198
194
|
|
|
@@ -203,7 +199,7 @@ module Html2rss
|
|
|
203
199
|
end
|
|
204
200
|
private_class_method :determine_verdict
|
|
205
201
|
|
|
206
|
-
def error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
|
|
202
|
+
def self.error_result(url_obj, error) # rubocop:disable Metrics/MethodLength
|
|
207
203
|
Result.new(
|
|
208
204
|
requested_url: url_obj,
|
|
209
205
|
final_url: url_obj,
|
|
@@ -226,14 +222,14 @@ module Html2rss
|
|
|
226
222
|
# @param cache_dir [String]
|
|
227
223
|
# @param cache_mutex [Mutex, nil]
|
|
228
224
|
# @return [void]
|
|
229
|
-
def cache_html_body(body, url_obj, cache_dir, cache_mutex)
|
|
225
|
+
def self.cache_html_body(body, url_obj, cache_dir, cache_mutex)
|
|
230
226
|
host = url_obj.host.to_s.delete_prefix('www.')
|
|
231
227
|
host = 'snapshot' if host.empty?
|
|
232
228
|
digest = Digest::SHA256.hexdigest(url_obj.to_s)[0, 12]
|
|
233
229
|
file_path = File.join(cache_dir, "#{host}-#{digest}.html")
|
|
234
230
|
writer = -> { File.write(file_path, body) }
|
|
235
231
|
cache_mutex ? cache_mutex.synchronize(&writer) : writer.call
|
|
236
|
-
rescue
|
|
232
|
+
rescue SystemCallError, IOError => error
|
|
237
233
|
Log.debug("Recon cache failed: #{error.message}")
|
|
238
234
|
end
|
|
239
235
|
private_class_method :cache_html_body
|
|
@@ -34,7 +34,8 @@ module Html2rss
|
|
|
34
34
|
/interstitial/i
|
|
35
35
|
],
|
|
36
36
|
message: 'Blocked surface detected: DataDome anti-bot challenge page. ' \
|
|
37
|
-
'Target a direct listing URL
|
|
37
|
+
'Target a direct listing URL. Clearance cookies from a successful Botasaurus scrape ' \
|
|
38
|
+
'are reused automatically; to supply your own, set request.botasaurus.cookies.'
|
|
38
39
|
},
|
|
39
40
|
{
|
|
40
41
|
key: :vercel_security_checkpoint,
|
|
@@ -79,10 +80,10 @@ module Html2rss
|
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
def self.interstitial_signature_match?(body, signature)
|
|
82
|
-
min_matches = signature
|
|
83
|
+
min_matches = signature[:min_matches]
|
|
83
84
|
matches = 0
|
|
84
85
|
|
|
85
|
-
signature
|
|
86
|
+
signature[:patterns].each do |pattern|
|
|
86
87
|
matches += 1 if pattern.match?(body)
|
|
87
88
|
return true if matches >= min_matches
|
|
88
89
|
end
|
|
@@ -91,11 +92,7 @@ module Html2rss
|
|
|
91
92
|
end
|
|
92
93
|
private_class_method :interstitial_signature_match?
|
|
93
94
|
|
|
94
|
-
def self.normalize_body(body)
|
|
95
|
-
body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
|
|
96
|
-
rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
97
|
-
body.to_s.force_encoding(Encoding::UTF_8).scrub
|
|
98
|
-
end
|
|
95
|
+
def self.normalize_body(body) = body.to_s.scrub
|
|
99
96
|
private_class_method :normalize_body
|
|
100
97
|
end
|
|
101
98
|
end
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# Closed set from OpenAPI ExecutionMode.
|
|
12
12
|
EXECUTION_MODES = Set['auto', 'request', 'browser'].freeze
|
|
13
13
|
# Closed set from OpenAPI NavigationMode.
|
|
14
|
-
NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass'
|
|
14
|
+
NAVIGATION_MODES = Set['auto', 'get', 'google_get', 'google_get_bypass'].freeze
|
|
15
15
|
# Closed set from OpenAPI ErrorCategory.
|
|
16
16
|
ERROR_CATEGORIES = Set['timeout', 'challenge_block', 'navigation_error', 'metadata_error', 'validation'].freeze
|
|
17
17
|
|
|
@@ -84,6 +84,15 @@ module Html2rss
|
|
|
84
84
|
# @return [String, nil] final URL reported by upstream
|
|
85
85
|
def final_url = payload['final_url']
|
|
86
86
|
|
|
87
|
+
# Session credentials: keep out of logs, transport_meta and feed output.
|
|
88
|
+
# @return [Hash{String => String}] cookies earned for the target host (absent or malformed → {})
|
|
89
|
+
def cookies
|
|
90
|
+
raw = payload['cookies']
|
|
91
|
+
return {} unless raw.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
raw.select { |name, value| name.is_a?(String) && value.is_a?(String) }
|
|
94
|
+
end
|
|
95
|
+
|
|
87
96
|
# @return [Hash{String => Object}] diagnostics plus success-only metadata_error (frozen)
|
|
88
97
|
def transport_meta
|
|
89
98
|
meta = diagnostics.dup
|
|
@@ -258,6 +267,7 @@ module Html2rss
|
|
|
258
267
|
# @param url [Html2rss::Url] canonical URL to scrape
|
|
259
268
|
# @param headers [Hash] request headers from context
|
|
260
269
|
# @param options [Hash] validated request.botasaurus options
|
|
270
|
+
# @param stored_cookies [Hash{String => String}] cookies replayed from the cookie store
|
|
261
271
|
# @option options [String] :execution_mode
|
|
262
272
|
# @option options [String] :navigation_mode
|
|
263
273
|
# @option options [Integer] :max_retries
|
|
@@ -275,18 +285,23 @@ module Html2rss
|
|
|
275
285
|
# @option options [String] :lang
|
|
276
286
|
# @option options [Hash] :cookies
|
|
277
287
|
# @option options [Hash] :headers
|
|
278
|
-
def initialize(url:, headers: {}, options: {})
|
|
288
|
+
def initialize(url:, headers: {}, options: {}, stored_cookies: {})
|
|
279
289
|
@url = url
|
|
280
290
|
@headers = headers
|
|
281
291
|
@options = options
|
|
292
|
+
@stored_cookies = stored_cookies
|
|
282
293
|
end
|
|
283
294
|
|
|
295
|
+
# @return [String] omits options and stored cookies (session credentials)
|
|
296
|
+
def inspect = "#<#{self.class} url=#{url}>"
|
|
297
|
+
|
|
284
298
|
# @return [Hash] payload for POST /scrape (explicit options plus wait cap)
|
|
285
299
|
def request_payload
|
|
286
300
|
payload = { url: url.to_s }.merge(filtered_options)
|
|
287
301
|
forwarded_headers = merged_headers
|
|
288
302
|
payload[:headers] = forwarded_headers if forwarded_headers&.any?
|
|
289
303
|
cap_wait_timeout!(payload)
|
|
304
|
+
merge_cookies!(payload)
|
|
290
305
|
payload
|
|
291
306
|
end
|
|
292
307
|
|
|
@@ -302,7 +317,7 @@ module Html2rss
|
|
|
302
317
|
|
|
303
318
|
private
|
|
304
319
|
|
|
305
|
-
attr_reader :url, :headers, :options
|
|
320
|
+
attr_reader :url, :headers, :options, :stored_cookies
|
|
306
321
|
|
|
307
322
|
def json_object(body)
|
|
308
323
|
payload = JSON.parse(body)
|
|
@@ -326,6 +341,13 @@ module Html2rss
|
|
|
326
341
|
end
|
|
327
342
|
end
|
|
328
343
|
|
|
344
|
+
# Stored cookies are replayed in every execution_mode; cookies from the feed config win.
|
|
345
|
+
def merge_cookies!(payload)
|
|
346
|
+
configured = (options[:cookies] || {}).transform_keys(&:to_s)
|
|
347
|
+
merged = stored_cookies.merge(configured)
|
|
348
|
+
merged.any? ? payload[:cookies] = merged : payload.delete(:cookies)
|
|
349
|
+
end
|
|
350
|
+
|
|
329
351
|
def cap_wait_timeout!(payload)
|
|
330
352
|
wait = payload[:wait_timeout_seconds]
|
|
331
353
|
return unless wait
|
|
@@ -8,7 +8,7 @@ module Html2rss
|
|
|
8
8
|
class RequestService
|
|
9
9
|
##
|
|
10
10
|
# Strategy to delegate fetching to a Botasaurus scrape API.
|
|
11
|
-
class BotasaurusStrategy < Strategy
|
|
11
|
+
class BotasaurusStrategy < Strategy # rubocop:disable Metrics/ClassLength -- transport + cookie-jar retry stay co-located
|
|
12
12
|
# Content-Type negotiation for the scrape API transport hop.
|
|
13
13
|
TRANSPORT_ACCEPT = 'application/json'
|
|
14
14
|
# Disable compressed bodies so HTTPX returns raw JSON without implicit decoding surprises.
|
|
@@ -18,16 +18,49 @@ module Html2rss
|
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
+
# With a stored jar, tries the cheap HTTP tier first; cookies never reach the browser tier,
|
|
22
|
+
# so a block falls back to a plain scrape without them.
|
|
21
23
|
def fetch
|
|
22
|
-
|
|
24
|
+
stored = jar.stored
|
|
25
|
+
return scrape({}) unless replay?(stored)
|
|
26
|
+
|
|
27
|
+
replay(stored)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replay?(stored) = botasaurus_options[:execution_mode] != 'browser' && jar.replayable?(stored)
|
|
31
|
+
|
|
32
|
+
def replay(stored)
|
|
33
|
+
response = scrape(stored, execution_mode: 'request')
|
|
34
|
+
jar.replayed(:pass, stored)
|
|
35
|
+
response
|
|
36
|
+
rescue BlockedSurfaceDetected
|
|
37
|
+
jar.replayed(:block, stored)
|
|
38
|
+
scrape({})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scrape(stored, overrides = {})
|
|
42
|
+
parsed_response = post_scrape_request(stored, overrides)
|
|
23
43
|
raise_from_error!(parsed_response) if parsed_response.is_a?(BotasaurusContract::Error)
|
|
24
44
|
|
|
25
|
-
build_response(parsed_response)
|
|
45
|
+
response = build_response(parsed_response)
|
|
46
|
+
ResponseGuard.new(policy: ctx.policy).inspect_body!(response.body)
|
|
47
|
+
jar.refresh(stored, response.cookies)
|
|
48
|
+
response
|
|
26
49
|
end
|
|
27
50
|
|
|
28
|
-
def
|
|
51
|
+
def jar
|
|
52
|
+
@jar ||= CookieJar.new(
|
|
53
|
+
host: ctx.url.host, scraper_base_url: scraper_base_url.to_s, proxy: botasaurus_options[:proxy],
|
|
54
|
+
user_agent: botasaurus_options[:user_agent] || ctx.headers['User-Agent']
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def botasaurus_options = ctx.request.fetch(:botasaurus, {})
|
|
59
|
+
|
|
60
|
+
def post_scrape_request(stored, overrides)
|
|
29
61
|
request_id = SecureRandom.uuid
|
|
30
62
|
Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
|
|
63
|
+
contract = contract(stored, overrides)
|
|
31
64
|
payload = JSON.generate(contract.request_payload)
|
|
32
65
|
response = client.post(scrape_endpoint, body: payload, headers: post_headers(request_id))
|
|
33
66
|
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
@@ -44,7 +77,8 @@ module Html2rss
|
|
|
44
77
|
url: response_url(parsed_response.final_url),
|
|
45
78
|
status: parsed_response.status,
|
|
46
79
|
transport_meta: parsed_response.transport_meta,
|
|
47
|
-
captured_responses: parsed_response.xhr_responses
|
|
80
|
+
captured_responses: parsed_response.xhr_responses,
|
|
81
|
+
cookies: parsed_response.cookies
|
|
48
82
|
)
|
|
49
83
|
end
|
|
50
84
|
|
|
@@ -65,15 +99,13 @@ module Html2rss
|
|
|
65
99
|
return ctx.url if final_url.nil?
|
|
66
100
|
|
|
67
101
|
Html2rss::Url.from_absolute(final_url)
|
|
68
|
-
rescue
|
|
102
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
69
103
|
ctx.url
|
|
70
104
|
end
|
|
71
105
|
|
|
72
|
-
def contract
|
|
73
|
-
|
|
74
|
-
url: ctx.url,
|
|
75
|
-
headers: ctx.headers,
|
|
76
|
-
options: ctx.request.fetch(:botasaurus, {})
|
|
106
|
+
def contract(stored, overrides)
|
|
107
|
+
BotasaurusContract.new(
|
|
108
|
+
url: ctx.url, headers: ctx.headers, options: botasaurus_options.merge(overrides), stored_cookies: stored
|
|
77
109
|
)
|
|
78
110
|
end
|
|
79
111
|
|
|
@@ -117,7 +149,7 @@ module Html2rss
|
|
|
117
149
|
raise BotasaurusConfigurationError, 'BOTASAURUS_SCRAPER_URL is required for strategy=botasaurus.'
|
|
118
150
|
end
|
|
119
151
|
Html2rss::Url.for_channel(configured)
|
|
120
|
-
rescue
|
|
152
|
+
rescue Html2rss::Url::InvalidUrlError => error
|
|
121
153
|
raise BotasaurusConfigurationError, "BOTASAURUS_SCRAPER_URL is invalid: #{error.message}"
|
|
122
154
|
end
|
|
123
155
|
end
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# @param max_requests [Integer] maximum HTTP request slots
|
|
12
12
|
# @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
|
|
13
13
|
def initialize(max_requests:, total_timeout_seconds: nil)
|
|
14
|
-
|
|
14
|
+
Option.integer(:max_requests, max_requests, min: 1)
|
|
15
15
|
|
|
16
16
|
@remaining_requests = max_requests
|
|
17
17
|
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -25,33 +25,20 @@ module Html2rss
|
|
|
25
25
|
# @return [Integer] remaining request slots after consumption
|
|
26
26
|
# @raise [RequestBudgetExceeded] if no request slots remain
|
|
27
27
|
def consume!
|
|
28
|
-
consume_request!
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
##
|
|
32
|
-
# Consumes one HTTP request slot.
|
|
33
|
-
#
|
|
34
|
-
# @return [Integer] remaining request slots after consumption
|
|
35
|
-
# @raise [RequestBudgetExceeded] if no request slots remain
|
|
36
|
-
def consume_request!
|
|
37
28
|
@mutex.synchronize do
|
|
38
29
|
raise RequestBudgetExceeded, 'Request budget exhausted' if @remaining_requests.zero?
|
|
39
30
|
|
|
40
31
|
@remaining_requests -= 1
|
|
41
32
|
end
|
|
42
33
|
end
|
|
43
|
-
|
|
44
|
-
##
|
|
45
|
-
# @return [Integer] HTTP request slots still available
|
|
46
|
-
def remaining
|
|
47
|
-
remaining_requests
|
|
48
|
-
end
|
|
34
|
+
alias consume_request! consume!
|
|
49
35
|
|
|
50
36
|
##
|
|
51
37
|
# @return [Integer] HTTP request slots still available
|
|
52
38
|
def remaining_requests
|
|
53
39
|
@mutex.synchronize { @remaining_requests }
|
|
54
40
|
end
|
|
41
|
+
alias remaining remaining_requests
|
|
55
42
|
|
|
56
43
|
##
|
|
57
44
|
# @return [Float, nil] the remaining timeout in seconds, or nil if not tracked
|
|
@@ -91,14 +78,6 @@ module Html2rss
|
|
|
91
78
|
def effective_timeout_ms(fallback:)
|
|
92
79
|
(effective_timeout_seconds(fallback:) * 1000).to_i
|
|
93
80
|
end
|
|
94
|
-
|
|
95
|
-
private
|
|
96
|
-
|
|
97
|
-
def validate_slot_limits!(max_requests:)
|
|
98
|
-
return if max_requests.is_a?(Integer) && max_requests.positive?
|
|
99
|
-
|
|
100
|
-
raise ArgumentError, 'max_requests must be positive'
|
|
101
|
-
end
|
|
102
81
|
end
|
|
103
82
|
end
|
|
104
83
|
end
|