html2rss 0.22.2 → 0.23.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 +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +206 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +118 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
- data/lib/html2rss/feed_builder/json_feed.rb +61 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +23 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +52 -65
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
- data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +90 -16
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +102 -6
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +25 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +63 -65
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +19 -19
- data/schema/html2rss-config.schema.json +109 -9
- metadata +68 -42
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
class RequestService
|
|
5
5
|
##
|
|
6
|
-
# Defines the interface
|
|
6
|
+
# Defines the guarded request interface for every strategy adapter.
|
|
7
|
+
#
|
|
8
|
+
# `#execute` always runs timeout check → budget/policy preflight → `#fetch`
|
|
9
|
+
# → ResponseGuard postflight so new adapters cannot skip shared controls.
|
|
7
10
|
class Strategy
|
|
8
11
|
##
|
|
9
12
|
# @param ctx [Context] the context for the request
|
|
@@ -12,11 +15,17 @@ module Html2rss
|
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
##
|
|
15
|
-
# Executes the request.
|
|
18
|
+
# Executes the request through the shared preflight/postflight path.
|
|
19
|
+
#
|
|
16
20
|
# @return [Response] the response from the strategy
|
|
17
|
-
# @raise [NotImplementedError] if the
|
|
21
|
+
# @raise [NotImplementedError] if the subclass does not implement `#fetch`
|
|
18
22
|
def execute
|
|
19
|
-
|
|
23
|
+
check_timeout!
|
|
24
|
+
response = perform_execute
|
|
25
|
+
postflight!(response, response_guard:)
|
|
26
|
+
response
|
|
27
|
+
rescue StandardError => error
|
|
28
|
+
handle_error(error)
|
|
20
29
|
end
|
|
21
30
|
|
|
22
31
|
private
|
|
@@ -24,9 +33,96 @@ module Html2rss
|
|
|
24
33
|
# @return [Context] the context for the request
|
|
25
34
|
attr_reader :ctx
|
|
26
35
|
|
|
36
|
+
##
|
|
37
|
+
# Adapter hook: perform the preflight, fetch, and optional retry lifecycle.
|
|
38
|
+
#
|
|
39
|
+
# @return [Response] normalized response
|
|
40
|
+
def perform_execute
|
|
41
|
+
preflight!
|
|
42
|
+
fetch
|
|
43
|
+
end
|
|
44
|
+
|
|
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
|
+
##
|
|
55
|
+
# Consumes a request slot and validates the destination before transport work.
|
|
56
|
+
#
|
|
57
|
+
# @param consume_budget [Boolean] whether to decrement the request budget
|
|
58
|
+
# @return [void]
|
|
59
|
+
def preflight!(consume_budget: true)
|
|
60
|
+
return if skip_preflight?
|
|
61
|
+
|
|
62
|
+
ctx.budget.consume! if consume_budget
|
|
63
|
+
ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# Enforces response-size and blocked-surface checks after transport work.
|
|
68
|
+
#
|
|
69
|
+
# @param response [Response, nil] adapter response
|
|
70
|
+
# @param response_guard [ResponseGuard, nil] existing guard to reuse (e.g. streaming)
|
|
71
|
+
# @return [void]
|
|
72
|
+
def postflight!(response, response_guard: nil)
|
|
73
|
+
return if skip_postflight? || response.nil?
|
|
74
|
+
|
|
75
|
+
guard = response_guard || ResponseGuard.new(policy: ctx.policy)
|
|
76
|
+
guard.inspect_body!(response.body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# @return [Boolean] true when remote budget/policy preflight must be skipped
|
|
81
|
+
def skip_preflight? = false
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# @return [Boolean] true when ResponseGuard postflight must be skipped
|
|
85
|
+
def skip_postflight? = false
|
|
86
|
+
|
|
27
87
|
def check_timeout!
|
|
28
|
-
|
|
29
|
-
|
|
88
|
+
ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [ResponseGuard, nil]
|
|
92
|
+
def response_guard = nil
|
|
93
|
+
|
|
94
|
+
# @param error [StandardError]
|
|
95
|
+
# @return [void]
|
|
96
|
+
# @raise [StandardError]
|
|
97
|
+
def handle_error(error)
|
|
98
|
+
if timeout_error?(error)
|
|
99
|
+
raise RequestTimedOut, error.message
|
|
100
|
+
elsif connection_error?(error)
|
|
101
|
+
translate_connection_error(error)
|
|
102
|
+
else
|
|
103
|
+
raise error
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @param error [StandardError]
|
|
108
|
+
# @return [void]
|
|
109
|
+
# @raise [StandardError]
|
|
110
|
+
def translate_connection_error(error)
|
|
111
|
+
raise error
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param error [StandardError]
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def timeout_error?(error)
|
|
117
|
+
error.is_a?(Faraday::TimeoutError) ||
|
|
118
|
+
error.is_a?(Timeout::Error) ||
|
|
119
|
+
(defined?(Puppeteer::TimeoutError) && error.is_a?(Puppeteer::TimeoutError))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param error [StandardError]
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def connection_error?(error)
|
|
125
|
+
error.is_a?(Faraday::ConnectionFailed) || error.is_a?(Faraday::SSLError)
|
|
30
126
|
end
|
|
31
127
|
end
|
|
32
128
|
end
|
|
@@ -6,7 +6,10 @@ require 'forwardable'
|
|
|
6
6
|
module Html2rss
|
|
7
7
|
##
|
|
8
8
|
# Requests website URLs to retrieve their HTML for further processing.
|
|
9
|
-
# Provides strategies
|
|
9
|
+
# Provides concrete transport strategies (e.g. Faraday, Browserless).
|
|
10
|
+
#
|
|
11
|
+
# Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
|
|
12
|
+
# it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
|
|
10
13
|
class RequestService
|
|
11
14
|
include Singleton
|
|
12
15
|
|
|
@@ -18,8 +21,10 @@ module Html2rss
|
|
|
18
21
|
class UnsupportedUrlScheme < Html2rss::Error; end
|
|
19
22
|
# Raised when a response type cannot be parsed.
|
|
20
23
|
class UnsupportedResponseContentType < Html2rss::Error; end
|
|
21
|
-
# Raised when request limits are exceeded.
|
|
24
|
+
# Raised when HTTP request slot limits are exceeded.
|
|
22
25
|
class RequestBudgetExceeded < Html2rss::Error; end
|
|
26
|
+
# Raised when Browserless preload interaction limits are exceeded.
|
|
27
|
+
class InteractionBudgetExceeded < Html2rss::Error; end
|
|
23
28
|
# Raised when policy denies private-network access.
|
|
24
29
|
class PrivateNetworkDenied < Html2rss::Error; end
|
|
25
30
|
# Raised when cross-origin follow-up requests are denied.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestSession
|
|
5
|
+
module Pager
|
|
6
|
+
##
|
|
7
|
+
# Abstract base class for pagination strategies.
|
|
8
|
+
class Base
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
# Default maximum number of pages to fetch if omitted.
|
|
12
|
+
# @return [Integer]
|
|
13
|
+
DEFAULT_MAX_PAGES = 5
|
|
14
|
+
|
|
15
|
+
# @param session [RequestSession] request session used to execute follow-ups
|
|
16
|
+
# @param initial_response [RequestService::Response] first page response
|
|
17
|
+
# @param config [Hash, Integer] pagination configuration or max_pages integer
|
|
18
|
+
# @param logger [Logger] logger used for pagination stop warnings
|
|
19
|
+
def initialize(session:, initial_response:, config: {}, logger: Html2rss::Log)
|
|
20
|
+
@session = session
|
|
21
|
+
@initial_response = initial_response
|
|
22
|
+
@config = config.is_a?(Hash) ? config : { max_pages: config }
|
|
23
|
+
@logger = logger
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @yield [RequestService::Response] each page response
|
|
27
|
+
# @return [Enumerator] enumerator when no block is given
|
|
28
|
+
def each
|
|
29
|
+
return enum_for(:each) unless block_given?
|
|
30
|
+
|
|
31
|
+
yield initial_response
|
|
32
|
+
|
|
33
|
+
current_response = initial_response
|
|
34
|
+
session.effective_page_budget(max_pages).pred.times do |index|
|
|
35
|
+
next_url = next_page_url(current_response, page_number: index + 2)
|
|
36
|
+
break unless follow_up_allowed?(next_url)
|
|
37
|
+
|
|
38
|
+
current_response = fetch_follow_up_response_or_stop(next_url, current_response.url)
|
|
39
|
+
break unless current_response
|
|
40
|
+
|
|
41
|
+
yield current_response
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :session, :initial_response, :config, :logger
|
|
48
|
+
|
|
49
|
+
# @return [Integer] configured maximum pages (always a positive Integer)
|
|
50
|
+
def max_pages
|
|
51
|
+
value = config.fetch(:max_pages, DEFAULT_MAX_PAGES)
|
|
52
|
+
value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_PAGES
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param next_url [Html2rss::Url, String, nil]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def follow_up_allowed?(next_url)
|
|
58
|
+
!next_url.nil? && !next_url.to_s.empty? && !session.visited?(next_url)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param next_url [Html2rss::Url, String]
|
|
62
|
+
# @param origin_url [Html2rss::Url, String]
|
|
63
|
+
# @return [RequestService::Response, nil]
|
|
64
|
+
def fetch_follow_up_response_or_stop(next_url, origin_url)
|
|
65
|
+
session.follow_up(url: next_url, relation: :pagination, origin_url:)
|
|
66
|
+
rescue RequestService::RequestBudgetExceeded => error
|
|
67
|
+
logger.warn(
|
|
68
|
+
"#{self.class}: pagination stopped at #{next_url} - #{error.message}. " \
|
|
69
|
+
"Retry with --max-requests #{session.max_requests + 1} or increase request.max_requests in the config."
|
|
70
|
+
)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param page_response [RequestService::Response]
|
|
75
|
+
# @param page_number [Integer] 1-based target page number (e.g. 2 for second page)
|
|
76
|
+
# @return [Html2rss::Url, String, nil]
|
|
77
|
+
def next_page_url(page_response, page_number:)
|
|
78
|
+
raise NotImplementedError, "#{self.class}#next_page_url must be implemented by subclass"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestSession
|
|
5
|
+
module Pager
|
|
6
|
+
##
|
|
7
|
+
# Extracts the next-page URL using a configurable CSS or XPath selector.
|
|
8
|
+
class CustomSelector < Base
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# @param page_response [RequestService::Response]
|
|
12
|
+
# @param page_number [Integer]
|
|
13
|
+
# @return [String, nil]
|
|
14
|
+
def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
|
+
selector = config[:selector]
|
|
16
|
+
return nil if selector.nil? || selector.empty?
|
|
17
|
+
|
|
18
|
+
node = extract_node(page_response.parsed_body, selector)
|
|
19
|
+
return nil unless node
|
|
20
|
+
|
|
21
|
+
href = node['href'] || node.text
|
|
22
|
+
return nil if href.nil? || href.strip.empty?
|
|
23
|
+
|
|
24
|
+
Html2rss::Url.from_relative(href.strip, page_response.url)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
28
|
+
# @param selector [String]
|
|
29
|
+
# @return [Nokogiri::XML::Node, nil]
|
|
30
|
+
def extract_node(parsed_body, selector)
|
|
31
|
+
parsed_body.at_css(selector) || parsed_body.at_xpath(selector)
|
|
32
|
+
rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
|
|
33
|
+
# Pure XPath like `descendant::a` fails LOOKS_LIKE_XPATH / at_css; fall back explicitly.
|
|
34
|
+
parsed_body.at_xpath(selector)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class RequestSession
|
|
7
|
+
module Pager
|
|
8
|
+
##
|
|
9
|
+
# Digs into JSON response payloads to extract next page URLs or cursor tokens.
|
|
10
|
+
class JsonCursor < Base
|
|
11
|
+
# Default query parameter name for cursor token pagination.
|
|
12
|
+
# @return [String]
|
|
13
|
+
DEFAULT_PARAM = 'cursor'
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# @param page_response [RequestService::Response]
|
|
18
|
+
# @param page_number [Integer]
|
|
19
|
+
# @return [String, nil]
|
|
20
|
+
def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
21
|
+
json_data = parse_json_body(page_response)
|
|
22
|
+
return nil unless json_data
|
|
23
|
+
|
|
24
|
+
url_from_next_path(json_data, page_response.url) ||
|
|
25
|
+
url_from_cursor_path(json_data, page_response.url)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param json_data [Hash]
|
|
29
|
+
# @param origin_url [Html2rss::Url]
|
|
30
|
+
# @return [String, nil]
|
|
31
|
+
def url_from_next_path(json_data, origin_url)
|
|
32
|
+
return nil unless (next_url_path = config[:next_url_path])
|
|
33
|
+
|
|
34
|
+
next_url = dig_path(json_data, next_url_path)
|
|
35
|
+
Html2rss::Url.from_relative(next_url.to_s, origin_url) if next_url && !next_url.to_s.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param json_data [Hash]
|
|
39
|
+
# @param origin_url [Html2rss::Url]
|
|
40
|
+
# @return [Html2rss::Url, nil]
|
|
41
|
+
def url_from_cursor_path(json_data, origin_url)
|
|
42
|
+
return nil unless (cursor_path = config[:cursor_path])
|
|
43
|
+
|
|
44
|
+
cursor_val = dig_path(json_data, cursor_path)
|
|
45
|
+
return nil if cursor_val.nil? || cursor_val.to_s.empty?
|
|
46
|
+
|
|
47
|
+
param = config.fetch(:param, DEFAULT_PARAM)
|
|
48
|
+
url = Html2rss::Url.from_absolute(origin_url)
|
|
49
|
+
url.with_query_values(url.query_values.merge(param.to_s => cursor_val.to_s))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param page_response [RequestService::Response]
|
|
53
|
+
# @return [Hash, nil]
|
|
54
|
+
def parse_json_body(page_response)
|
|
55
|
+
JSON.parse(page_response.body)
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param hash [Hash]
|
|
61
|
+
# @param path [String, Symbol]
|
|
62
|
+
# @return [Object, nil]
|
|
63
|
+
def dig_path(hash, path)
|
|
64
|
+
return nil unless hash.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
keys = path.to_s.split('.')
|
|
67
|
+
result = hash
|
|
68
|
+
keys.each do |key|
|
|
69
|
+
return nil unless result.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
result = result[key] || result[key.to_sym]
|
|
72
|
+
end
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestSession
|
|
5
|
+
module Pager
|
|
6
|
+
##
|
|
7
|
+
# Paginate using numeric offset increments in query parameters or URL path templates.
|
|
8
|
+
class Offset < Base
|
|
9
|
+
# Default query parameter name for offset pagination.
|
|
10
|
+
# @return [String]
|
|
11
|
+
DEFAULT_PARAM = 'offset'
|
|
12
|
+
|
|
13
|
+
# Default starting numeric offset value.
|
|
14
|
+
# @return [Integer]
|
|
15
|
+
DEFAULT_START_OFFSET = 0
|
|
16
|
+
|
|
17
|
+
# Default offset increment per page.
|
|
18
|
+
# @return [Integer]
|
|
19
|
+
DEFAULT_INCREMENT = 20
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @param page_response [RequestService::Response]
|
|
24
|
+
# @param page_number [Integer]
|
|
25
|
+
# @return [Html2rss::Url]
|
|
26
|
+
def next_page_url(page_response, page_number:)
|
|
27
|
+
param = config.fetch(:param, DEFAULT_PARAM)
|
|
28
|
+
target_offset_val = target_offset_for(page_number)
|
|
29
|
+
url = Html2rss::Url.from_absolute(page_response.url)
|
|
30
|
+
|
|
31
|
+
if url.to_s.include?('{offset}')
|
|
32
|
+
Html2rss::Url.from_absolute(url.to_s.gsub('{offset}', target_offset_val.to_s))
|
|
33
|
+
else
|
|
34
|
+
url.with_query_values(url.query_values.merge(param.to_s => target_offset_val.to_s))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param page_number [Integer]
|
|
39
|
+
# @return [Integer]
|
|
40
|
+
def target_offset_for(page_number)
|
|
41
|
+
start_offset = config.fetch(:start_offset, DEFAULT_START_OFFSET)
|
|
42
|
+
increment = config.fetch(:increment, DEFAULT_INCREMENT)
|
|
43
|
+
start_offset + ((page_number - 1) * increment)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestSession
|
|
5
|
+
module Pager
|
|
6
|
+
##
|
|
7
|
+
# Traverses a rel=next pagination chain for HTML documents.
|
|
8
|
+
class RelNext < Base
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# @param page_response [RequestService::Response]
|
|
12
|
+
# @param page_number [Integer]
|
|
13
|
+
# @return [String, nil]
|
|
14
|
+
def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
|
+
href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
|
|
16
|
+
return nil if href.nil? || href.empty?
|
|
17
|
+
|
|
18
|
+
Html2rss::Url.from_relative(href, page_response.url)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestSession
|
|
5
|
+
module Pager
|
|
6
|
+
##
|
|
7
|
+
# Paginate using page number increments in query parameters or URL path templates.
|
|
8
|
+
class UrlTemplate < Base
|
|
9
|
+
# Default query parameter name for page number pagination.
|
|
10
|
+
# @return [String]
|
|
11
|
+
DEFAULT_PARAM = 'page'
|
|
12
|
+
|
|
13
|
+
# Default starting page number.
|
|
14
|
+
# @return [Integer]
|
|
15
|
+
DEFAULT_START_PAGE = 1
|
|
16
|
+
|
|
17
|
+
# Default page number step increment.
|
|
18
|
+
# @return [Integer]
|
|
19
|
+
DEFAULT_STEP = 1
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @param page_response [RequestService::Response]
|
|
24
|
+
# @param page_number [Integer] 1-based target page number (e.g. 2 for 2nd page)
|
|
25
|
+
# @return [Html2rss::Url]
|
|
26
|
+
def next_page_url(page_response, page_number:)
|
|
27
|
+
param = config.fetch(:param, DEFAULT_PARAM)
|
|
28
|
+
target_page_val = target_page_for(page_number)
|
|
29
|
+
url = Html2rss::Url.from_absolute(page_response.url)
|
|
30
|
+
|
|
31
|
+
if url.to_s.include?('{page}')
|
|
32
|
+
Html2rss::Url.from_absolute(url.to_s.gsub('{page}', target_page_val.to_s))
|
|
33
|
+
else
|
|
34
|
+
url.with_query_values(url.query_values.merge(param.to_s => target_page_val.to_s))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param page_number [Integer]
|
|
39
|
+
# @return [Integer]
|
|
40
|
+
def target_page_for(page_number)
|
|
41
|
+
start_page = config.fetch(:start_page, DEFAULT_START_PAGE)
|
|
42
|
+
step = config.fetch(:step, DEFAULT_STEP)
|
|
43
|
+
start_page + ((page_number - 1) * step)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestSession
|
|
5
|
+
##
|
|
6
|
+
# Factory for building pagination strategy instances.
|
|
7
|
+
module Pager
|
|
8
|
+
# Mapping of strategy symbols to strategy class implementations.
|
|
9
|
+
# @return [Hash{Symbol => Class}]
|
|
10
|
+
STRATEGIES = {
|
|
11
|
+
rel_next: RelNext,
|
|
12
|
+
custom_selector: CustomSelector,
|
|
13
|
+
url_template: UrlTemplate,
|
|
14
|
+
offset: Offset,
|
|
15
|
+
json_cursor: JsonCursor
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# Returns the supported pagination strategy names.
|
|
20
|
+
#
|
|
21
|
+
# @return [Array<String>] strategy names derived from {STRATEGIES}
|
|
22
|
+
def self.strategy_names
|
|
23
|
+
STRATEGIES.keys.map(&:to_s).freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Returns the number of request slots needed for the pagination configuration.
|
|
28
|
+
#
|
|
29
|
+
# @param config [Hash, Integer, nil] pagination configuration
|
|
30
|
+
# @return [Integer] number of follow-up requests needed
|
|
31
|
+
def self.request_slots_for(config)
|
|
32
|
+
return 0 unless config
|
|
33
|
+
|
|
34
|
+
max_pages = normalize_config(config)[:max_pages] || Base::DEFAULT_MAX_PAGES
|
|
35
|
+
[max_pages - 1, 0].max
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# Returns a pager instance for the provided configuration.
|
|
40
|
+
#
|
|
41
|
+
# @param config [Hash, Integer, nil] pagination configuration
|
|
42
|
+
# @param session [RequestSession] request session used to execute follow-ups
|
|
43
|
+
# @param initial_response [RequestService::Response] initial page response
|
|
44
|
+
# @return [RequestSession::Pager::Base] pager strategy instance
|
|
45
|
+
def self.for(config, session:, initial_response:)
|
|
46
|
+
normalized_config = normalize_config(config)
|
|
47
|
+
strategy_name = normalized_config.fetch(:strategy, :rel_next).to_sym
|
|
48
|
+
|
|
49
|
+
klass = STRATEGIES.fetch(strategy_name) do
|
|
50
|
+
raise ArgumentError, "Unknown pagination strategy: #{strategy_name}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
klass.new(session:, initial_response:, config: normalized_config)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Normalizes integer, hash, or nil inputs into a strategy config hash.
|
|
58
|
+
#
|
|
59
|
+
# @param config [Hash, Integer, nil]
|
|
60
|
+
# @return [Hash]
|
|
61
|
+
def self.normalize_config(config)
|
|
62
|
+
case config
|
|
63
|
+
when Integer
|
|
64
|
+
{ max_pages: config, strategy: :rel_next }
|
|
65
|
+
when Hash
|
|
66
|
+
normalized = config.transform_keys(&:to_sym)
|
|
67
|
+
normalized[:strategy] = (normalized[:strategy] || :rel_next).to_sym
|
|
68
|
+
normalized
|
|
69
|
+
else
|
|
70
|
+
{ max_pages: Base::DEFAULT_MAX_PAGES, strategy: :rel_next }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -6,26 +6,22 @@ module Html2rss
|
|
|
6
6
|
class RequestSession
|
|
7
7
|
class << self
|
|
8
8
|
##
|
|
9
|
-
# Builds a request session from
|
|
9
|
+
# Builds a request session from config, strategy, and shared budget/policy.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# Context owns URL/header/request normalization once; callers must not
|
|
12
|
+
# pre-normalize through a passthrough bag.
|
|
13
|
+
#
|
|
14
|
+
# @param config [Html2rss::Config] validated feed config
|
|
15
|
+
# @param strategy [Symbol] request strategy for the session
|
|
16
|
+
# @param budget [RequestService::Budget] shared request budget
|
|
17
|
+
# @param policy [RequestService::Policy] request policy (from FeedPipeline::RuntimePolicy.resources_for)
|
|
13
18
|
# @param logger [Logger] logger used for operational warnings
|
|
14
19
|
# @return [RequestSession] configured request session
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
url:
|
|
18
|
-
headers: runtime_input.headers,
|
|
19
|
-
request: runtime_input.request,
|
|
20
|
-
policy: runtime_input.request_policy
|
|
21
|
-
}
|
|
22
|
-
context_options[:budget] = budget unless budget.nil?
|
|
23
|
-
|
|
24
|
-
new(
|
|
25
|
-
context: RequestService::Context.new(**context_options),
|
|
26
|
-
strategy: runtime_input.strategy,
|
|
27
|
-
logger:
|
|
20
|
+
def build(config:, strategy:, budget:, policy:, logger: Html2rss::Log)
|
|
21
|
+
context = RequestService::Context.new(
|
|
22
|
+
url: config.url, headers: config.headers, request: config.request, policy:, budget:
|
|
28
23
|
)
|
|
24
|
+
new(context:, strategy:, logger:)
|
|
29
25
|
end
|
|
30
26
|
end
|
|
31
27
|
|
|
@@ -48,6 +44,19 @@ module Html2rss
|
|
|
48
44
|
execute(context).tap { |response| remember!(response.url) }
|
|
49
45
|
end
|
|
50
46
|
|
|
47
|
+
##
|
|
48
|
+
# Returns an enumerable sequence of page responses for the given initial response
|
|
49
|
+
# and optional pagination configuration.
|
|
50
|
+
#
|
|
51
|
+
# @param initial_response [RequestService::Response] initial page response
|
|
52
|
+
# @param pagination_config [Hash, Integer, nil] pagination configuration
|
|
53
|
+
# @return [Enumerable<RequestService::Response>] page responses
|
|
54
|
+
def page_responses(initial_response, pagination_config: nil)
|
|
55
|
+
return [initial_response] unless pagination_config
|
|
56
|
+
|
|
57
|
+
Pager.for(pagination_config, session: self, initial_response:)
|
|
58
|
+
end
|
|
59
|
+
|
|
51
60
|
##
|
|
52
61
|
# Executes a follow-up request sharing policy, headers, and budget.
|
|
53
62
|
#
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class Selectors
|
|
5
|
+
##
|
|
6
|
+
# Per-item extraction scope: owns the article node and page +base_url+ for one
|
|
7
|
+
# extraction pass. Channel hashes are injected (and cached) by {Selectors}.
|
|
8
|
+
#
|
|
9
|
+
# Distinct from {Context}, which is the post-processor invocation bag (+options+).
|
|
10
|
+
# Post-processor +config+ is derived from {#channel} — not a parallel bag.
|
|
11
|
+
ItemScope = Data.define(:item, :base_url, :scraper, :channel) do
|
|
12
|
+
##
|
|
13
|
+
# Selects an attribute using this scope's item and base_url.
|
|
14
|
+
#
|
|
15
|
+
# @param name [Symbol, String]
|
|
16
|
+
# @return [Object, Array<Object>]
|
|
17
|
+
def select(name) = scraper.select_in_scope(name, self)
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# Builds a post-processor {Context} carrying this scope for nested selects.
|
|
21
|
+
#
|
|
22
|
+
# @param options [Hash] post-processor options from the selector config
|
|
23
|
+
# @option options [String] :name post-processor name
|
|
24
|
+
# @return [Context]
|
|
25
|
+
def context_for(options:)
|
|
26
|
+
Context.new(options:, item:, config: { channel: }, scraper:, item_scope: self)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -29,7 +29,7 @@ module Html2rss
|
|
|
29
29
|
# @param context [Selectors::Context] call-site context used for richer validation errors
|
|
30
30
|
# @return [void]
|
|
31
31
|
# @raise [InvalidType] if the value is not of the expected type(s)
|
|
32
|
-
def self.assert_type(value, types
|
|
32
|
+
def self.assert_type(value, types, name, context:)
|
|
33
33
|
return if Array(types).any? { |type| value.is_a?(type) }
|
|
34
34
|
|
|
35
35
|
options = if context.respond_to?(:options)
|