html2rss 0.22.2 → 0.24.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 +213 -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/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- 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 +198 -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/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -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 +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- 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 +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- 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 +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- 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.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- 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 +29 -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 +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- 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/schema/list_item.rb +0 -28
- 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
|
@@ -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
|
#
|
|
@@ -100,6 +109,10 @@ module Html2rss
|
|
|
100
109
|
visited_urls.add(normalize_url(url))
|
|
101
110
|
end
|
|
102
111
|
|
|
112
|
+
##
|
|
113
|
+
# @return [Html2rss::Url] the session's current request URL
|
|
114
|
+
def url = context.url
|
|
115
|
+
|
|
103
116
|
private
|
|
104
117
|
|
|
105
118
|
attr_reader :context, :strategy, :logger, :visited_urls
|
|
@@ -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)
|
|
@@ -42,6 +42,10 @@ module Html2rss
|
|
|
42
42
|
|
|
43
43
|
string = context[:options]&.dig(:string).to_s
|
|
44
44
|
raise InvalidType, 'The `string` template is absent.' if string.empty?
|
|
45
|
+
|
|
46
|
+
return if context.item_scope
|
|
47
|
+
|
|
48
|
+
raise MissingOption, 'The post-processor context is missing `item_scope`.', [], cause: nil
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
##
|
|
@@ -51,8 +55,6 @@ module Html2rss
|
|
|
51
55
|
super
|
|
52
56
|
|
|
53
57
|
@options = context[:options] || {}
|
|
54
|
-
@scraper = context[:scraper]
|
|
55
|
-
@item = context[:item]
|
|
56
58
|
@string = @options[:string].to_s
|
|
57
59
|
@getter = ->(key) { item_value(key) }
|
|
58
60
|
end
|
|
@@ -69,7 +71,9 @@ module Html2rss
|
|
|
69
71
|
# @return [String]
|
|
70
72
|
def item_value(key)
|
|
71
73
|
key = key.to_sym
|
|
72
|
-
key == :self
|
|
74
|
+
return value if key == :self
|
|
75
|
+
|
|
76
|
+
@context.item_scope.select(key)
|
|
73
77
|
end
|
|
74
78
|
end
|
|
75
79
|
end
|