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.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -108
  3. data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
  4. data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
  5. data/lib/html2rss/article.rb +206 -0
  6. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
  7. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
  8. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
  9. data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
  10. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
  11. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
  12. data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
  13. data/lib/html2rss/auto_source/discovery.rb +14 -0
  14. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
  15. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
  16. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
  17. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
  18. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
  19. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
  20. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
  21. data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
  22. data/lib/html2rss/auto_source/scraper/html.rb +22 -33
  23. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  24. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
  25. data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
  27. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  28. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  29. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  30. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  31. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  32. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  33. data/lib/html2rss/auto_source/scraper.rb +10 -3
  34. data/lib/html2rss/auto_source.rb +30 -35
  35. data/lib/html2rss/channel.rb +118 -0
  36. data/lib/html2rss/cli.rb +5 -6
  37. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  38. data/lib/html2rss/config/request_controls.rb +136 -0
  39. data/lib/html2rss/config/request_headers.rb +1 -1
  40. data/lib/html2rss/config/schema.rb +65 -114
  41. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  42. data/lib/html2rss/config/validator.rb +6 -6
  43. data/lib/html2rss/config.rb +176 -19
  44. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  45. data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
  46. data/lib/html2rss/feed_builder/json_feed.rb +61 -0
  47. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  48. data/lib/html2rss/feed_builder/rss.rb +116 -0
  49. data/lib/html2rss/feed_builder.rb +23 -0
  50. data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
  51. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  52. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  53. data/lib/html2rss/feed_pipeline.rb +52 -65
  54. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  55. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  56. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  57. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  59. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  60. data/lib/html2rss/html/article_extractor.rb +134 -0
  61. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  62. data/lib/html2rss/html/navigator.rb +146 -0
  63. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  64. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  65. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  66. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  67. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  68. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering.rb +25 -0
  70. data/lib/html2rss/html.rb +8 -0
  71. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
  74. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  75. data/lib/html2rss/request_service/budget.rb +90 -16
  76. data/lib/html2rss/request_service/context.rb +1 -3
  77. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  78. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  79. data/lib/html2rss/request_service/network_guard.rb +125 -0
  80. data/lib/html2rss/request_service/policy.rb +10 -82
  81. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  82. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  83. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  84. data/lib/html2rss/request_service/response_guard.rb +1 -1
  85. data/lib/html2rss/request_service/strategy.rb +102 -6
  86. data/lib/html2rss/request_service.rb +7 -2
  87. data/lib/html2rss/request_session/pager/base.rb +83 -0
  88. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  89. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  90. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  91. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  92. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  93. data/lib/html2rss/request_session/pager.rb +75 -0
  94. data/lib/html2rss/request_session.rb +25 -16
  95. data/lib/html2rss/selectors/item_scope.rb +30 -0
  96. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  97. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  98. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  99. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  100. data/lib/html2rss/selectors.rb +63 -65
  101. data/lib/html2rss/version.rb +1 -3
  102. data/lib/html2rss.rb +19 -19
  103. data/schema/html2rss-config.schema.json +109 -9
  104. metadata +68 -42
  105. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  106. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  107. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  108. data/lib/html2rss/blocked_surface.rb +0 -65
  109. data/lib/html2rss/category_extractor.rb +0 -116
  110. data/lib/html2rss/config/class_methods.rb +0 -183
  111. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  112. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  113. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  114. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  115. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  116. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  117. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  118. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  119. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  120. data/lib/html2rss/html_extractor.rb +0 -190
  121. data/lib/html2rss/html_navigator.rb +0 -71
  122. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  123. data/lib/html2rss/json_feed_builder.rb +0 -59
  124. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  125. data/lib/html2rss/rendering/description_builder.rb +0 -87
  126. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  127. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  128. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  129. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  130. data/lib/html2rss/rendering.rb +0 -23
  131. data/lib/html2rss/request_controls.rb +0 -133
  132. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  133. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  134. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  135. data/lib/html2rss/rss_builder/article.rb +0 -208
  136. data/lib/html2rss/rss_builder/channel.rb +0 -118
  137. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  138. data/lib/html2rss/rss_builder.rb +0 -101
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedBuilder
5
+ class JsonFeed
6
+ ##
7
+ # Maps an {Html2rss::Article} to a JSONFeed 1.1 item hash.
8
+ class Item
9
+ ##
10
+ # @param article [Html2rss::Article]
11
+ def initialize(article)
12
+ @article = article
13
+ end
14
+
15
+ ##
16
+ # @return [Hash, nil] the JSONFeed-compliant item hash
17
+ def to_h
18
+ content = content_fields
19
+ return if content.empty?
20
+
21
+ item_payload.merge(content).compact
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :article
27
+
28
+ ##
29
+ # @return [Hash]
30
+ def item_payload
31
+ {
32
+ id: article.guid,
33
+ url: article.url&.to_s,
34
+ title: article.title,
35
+ image: article.image&.to_s,
36
+ date_published: article.published_at&.iso8601,
37
+ authors: author_array,
38
+ tags:,
39
+ attachments:
40
+ }
41
+ end
42
+
43
+ ##
44
+ # @return [Array<Hash>, nil]
45
+ def author_array
46
+ return unless (name = article.author)
47
+
48
+ [{ name: }]
49
+ end
50
+
51
+ ##
52
+ # JSON Feed items must include content_html or content_text.
53
+ # @return [Hash]
54
+ def content_fields
55
+ description = article.description
56
+ return { content_html: description } if description
57
+
58
+ title = article.title
59
+ return { content_text: title } if title
60
+
61
+ {}
62
+ end
63
+
64
+ ##
65
+ # @return [Array<String>, nil]
66
+ def tags
67
+ cats = article.categories
68
+ cats.empty? ? nil : cats
69
+ end
70
+
71
+ ##
72
+ # Maps enclosures to JSONFeed attachment objects.
73
+ # @return [Array<Hash>, nil]
74
+ def attachments
75
+ enclosures = article.enclosures
76
+ return nil if enclosures.empty?
77
+
78
+ enclosures.map { |enc| attachment_hash(enc) }
79
+ end
80
+
81
+ ##
82
+ # @param enclosure [Html2rss::Article::Enclosure]
83
+ # @return [Hash]
84
+ def attachment_hash(enclosure)
85
+ size = enclosure.bytes_length
86
+
87
+ {
88
+ url: enclosure.url.to_s,
89
+ mime_type: enclosure.type,
90
+ size_in_bytes: size&.positive? ? size : nil
91
+ }.compact
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedBuilder
5
+ ##
6
+ # Builds a JSONFeed 1.1 hash from channel metadata and articles.
7
+ #
8
+ # @see https://www.jsonfeed.org/version/1.1/
9
+ class JsonFeed
10
+ # Official JSON Feed 1.1 schema version URL.
11
+ VERSION_URL = 'https://jsonfeed.org/version/1.1'
12
+
13
+ ##
14
+ # @param channel [Html2rss::Channel]
15
+ # @param articles [Array<Html2rss::Article>]
16
+ def initialize(channel:, articles:)
17
+ @channel = channel
18
+ @articles = articles
19
+ end
20
+
21
+ ##
22
+ # Builds and returns the JSONFeed hash.
23
+ #
24
+ # @return [Hash] the JSONFeed-compliant hash
25
+ def call
26
+ base_payload.merge(authors: author_array, items: item_hashes).compact
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :channel, :articles
32
+
33
+ ##
34
+ # @return [Hash]
35
+ def base_payload
36
+ {
37
+ version: VERSION_URL,
38
+ title: channel.title,
39
+ home_page_url: channel.url.to_s,
40
+ description: channel.description,
41
+ language: channel.language,
42
+ icon: channel.image&.to_s
43
+ }
44
+ end
45
+
46
+ ##
47
+ # @return [Array<Hash>]
48
+ def item_hashes
49
+ articles.filter_map { |article| Item.new(article).to_h }
50
+ end
51
+
52
+ ##
53
+ # @return [Array<Hash>, nil]
54
+ def author_array
55
+ return unless (name = channel.author)
56
+
57
+ [{ name: }]
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedBuilder
5
+ class Rss
6
+ ##
7
+ # Represents a stylesheet.
8
+ class Stylesheet
9
+ class << self
10
+ ##
11
+ # Adds the stylesheet XML tags to the RSS.
12
+ #
13
+ # @param maker [RSS::Maker::RSS20] RSS maker object.
14
+ # @param stylesheets [Array<Html2rss::FeedBuilder::Rss::Stylesheet>] Array of stylesheet configurations.
15
+ # @return [nil]
16
+ def add(maker, stylesheets)
17
+ stylesheets.each do |stylesheet|
18
+ add_stylesheet(maker, stylesheet)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ ##
25
+ # Adds a single Stylesheet to the RSS.
26
+ #
27
+ # @param maker [RSS::Maker::RSS20] RSS maker object.
28
+ # @param stylesheet [Html2rss::FeedBuilder::Rss::Stylesheet] Stylesheet configuration.
29
+ # @return [nil]
30
+ def add_stylesheet(maker, stylesheet)
31
+ maker.xml_stylesheets.new_xml_stylesheet do |xss|
32
+ xss.href = stylesheet.href
33
+ xss.type = stylesheet.type
34
+ xss.media = stylesheet.media
35
+ end
36
+ end
37
+ end
38
+
39
+ # Allowed stylesheet MIME types for RSS processing instructions.
40
+ TYPES = ['text/css', 'text/xsl'].to_set.freeze
41
+
42
+ # @param href [String] stylesheet URL
43
+ # @param type [String] MIME type (`text/css` or `text/xsl`)
44
+ # @param media [String] media query hint for the stylesheet
45
+ def initialize(href:, type:, media: 'all')
46
+ raise ArgumentError, 'stylesheet.href must be a String' unless href.is_a?(String)
47
+ raise ArgumentError, 'stylesheet.type invalid' unless TYPES.include?(type)
48
+ raise ArgumentError, 'stylesheet.media must be a String' unless media.is_a?(String)
49
+
50
+ @href = href
51
+ @type = type
52
+ @media = media
53
+ end
54
+ attr_reader :href, :type, :media
55
+
56
+ # @return [String] the XML representation of the stylesheet
57
+ def to_xml
58
+ <<~XML
59
+ <?xml-stylesheet href="#{href}" type="#{type}" media="#{media}"?>
60
+ XML
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rss'
4
+
5
+ module Html2rss
6
+ module FeedBuilder
7
+ ##
8
+ # Builds an RSS Feed by providing channel, articles and stylesheets.
9
+ class Rss
10
+ class << self
11
+ # @param article [Html2rss::Article] source article
12
+ # @param item_maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
13
+ # @return [void]
14
+ def add_item(article, item_maker)
15
+ add_item_string_values(article, item_maker)
16
+ add_item_categories(article, item_maker)
17
+ add_enclosure(article.enclosure, item_maker)
18
+ add_item_guid(article, item_maker)
19
+ end
20
+
21
+ # @param enclosure [Html2rss::Article::Enclosure, nil] built enclosure object for the current RSS item
22
+ # @param maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
23
+ # @return [void]
24
+ def add_enclosure(enclosure, maker)
25
+ return unless enclosure
26
+
27
+ maker.enclosure.tap do |enclosure_maker|
28
+ enclosure_maker.url = enclosure.url.to_s
29
+ enclosure_maker.type = enclosure.type
30
+ enclosure_maker.length = enclosure.bytes_length
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def add_item_string_values(article, item_maker)
37
+ %i[title description author].each do |attr|
38
+ next unless (value = article.send(attr))
39
+ next if value.empty?
40
+
41
+ item_maker.send(:"#{attr}=", value)
42
+ end
43
+
44
+ item_maker.link = article.url.to_s if article.url
45
+ item_maker.pubDate = article.published_at&.rfc2822
46
+ end
47
+
48
+ def add_item_categories(article, item_maker)
49
+ article.categories.each { |category| item_maker.categories.new_category.content = category }
50
+ end
51
+
52
+ def add_item_guid(article, item_maker)
53
+ item_maker.guid.tap do |guid|
54
+ guid.content = article.guid
55
+ guid.isPermaLink = false
56
+ end
57
+ end
58
+ end
59
+
60
+ ##
61
+ # @param channel [Html2rss::Channel] The channel information for the RSS feed.
62
+ # @param articles [Array<Html2rss::Article>] The list of articles to include in the RSS feed.
63
+ # @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
64
+ def initialize(channel:, articles:, stylesheets: [])
65
+ @channel = channel
66
+ @articles = articles
67
+ @stylesheets = stylesheets
68
+ end
69
+
70
+ # @return [RSS::Rss] RSS 2.0 document instance
71
+ def call
72
+ RSS::Maker.make('2.0') do |maker|
73
+ Stylesheet.add(maker, stylesheets)
74
+
75
+ make_channel(maker.channel)
76
+ make_items(maker)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :channel, :articles
83
+
84
+ def stylesheets
85
+ @stylesheets.map { |style| Stylesheet.new(**style) }
86
+ end
87
+
88
+ def make_channel(maker)
89
+ %i[language title description ttl].each do |key|
90
+ maker.public_send(:"#{key}=", channel.public_send(key))
91
+ end
92
+
93
+ maker.link = channel.url.to_s
94
+ maker.generator = generator
95
+ maker.updated = channel.last_build_date
96
+ end
97
+
98
+ def make_items(maker)
99
+ articles.each do |article|
100
+ maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
101
+ end
102
+ end
103
+
104
+ def generator
105
+ scraper_namespace_regex = /(?<namespace>Html2rss|Scraper)::/
106
+
107
+ scraper_counts = articles.flat_map(&:scraper).tally.map do |klass, count|
108
+ scraper_name = klass.to_s.gsub(scraper_namespace_regex, '')
109
+ "#{scraper_name} (#{count})"
110
+ end
111
+
112
+ "html2rss V. #{Html2rss::VERSION} (scrapers: #{scraper_counts.join(', ')})"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ # Entrypoint and namespace for feed building and formatting (RSS 2.0 / JSON Feed 1.1).
5
+ module FeedBuilder
6
+ # Builds the requested feed type from the channel and article list.
7
+ #
8
+ # @param type [Symbol] :rss or :json_feed
9
+ # @param channel [Html2rss::Channel]
10
+ # @param articles [Array<Html2rss::Article>]
11
+ # @return [RSS::Rss, Hash] format-compliant representation of the feed
12
+ def self.build(type, channel:, articles:, **)
13
+ case type
14
+ when :rss
15
+ Rss.new(channel:, articles:, **).call
16
+ when :json_feed
17
+ JsonFeed.new(channel:, articles:).call
18
+ else
19
+ raise ArgumentError, "Unknown feed type: #{type}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- ##
4
- # The Html2rss namespace.
5
3
  module Html2rss
6
- ##
7
- # Coordinates feed generation pipeline stages.
8
4
  class FeedPipeline
9
- # Retries feed extraction across concrete request strategies for :auto mode.
5
+ # Retries feed extraction across concrete request strategies for the :auto plan.
6
+ #
7
+ # Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
10
8
  class AutoFallback
11
- # Ordered list of concrete request strategies attempted by auto mode.
9
+ # Ordered list of concrete request strategies attempted by the :auto plan.
12
10
  CHAIN = %i[faraday botasaurus browserless].freeze
13
11
 
14
12
  # Error classes that should abort auto fallback immediately.
@@ -24,6 +22,41 @@ module Html2rss
24
22
  RequestService::BrowserlessConfigurationError
25
23
  ].freeze
26
24
 
25
+ ##
26
+ # Mutable run state for one auto-fallback chain: attempts plus selected result.
27
+ class AttemptState
28
+ attr_reader :attempts, :result
29
+
30
+ def initialize
31
+ @attempts = []
32
+ @result = nil
33
+ end
34
+
35
+ # @return [Boolean] true when a strategy already yielded items
36
+ def succeeded? = !result.nil?
37
+
38
+ # @param strategy [Symbol] strategy that raised
39
+ # @param error [Exception] caught error
40
+ # @return [void]
41
+ def record_error(strategy:, error:)
42
+ @attempts << { strategy:, items_count: nil, error_class: error.class.name }
43
+ end
44
+
45
+ # @param strategy [Symbol] strategy that returned a response
46
+ # @param items_count [Integer] extracted article count
47
+ # @return [void]
48
+ def record_items(strategy:, items_count:)
49
+ @attempts << { strategy:, items_count:, error_class: nil }
50
+ end
51
+
52
+ # @param response [RequestService::Response] successful response
53
+ # @param articles [Array] extracted articles
54
+ # @return [void]
55
+ def succeed!(response:, articles:)
56
+ @result = { response:, articles: }
57
+ end
58
+ end
59
+
27
60
  ##
28
61
  # @param strategies [Array<Symbol>] ordered concrete strategies for fallback
29
62
  # @param budget [RequestService::Budget] shared request budget across retries
@@ -40,10 +73,10 @@ module Html2rss
40
73
  ##
41
74
  # @return [Hash{Symbol => Object}] pipeline state containing :response and :articles
42
75
  def call
43
- state, attempts = run_attempts
44
- return state if state
76
+ state = run_attempts
77
+ return state.result if state.succeeded?
45
78
 
46
- finalize_failure(attempts:)
79
+ finalize_failure(attempts: state.attempts)
47
80
  end
48
81
 
49
82
  private
@@ -51,64 +84,48 @@ module Html2rss
51
84
  attr_reader :strategies, :budget, :session_for, :articles_for
52
85
 
53
86
  def run_attempts
54
- state = { result: nil, attempts: [] }
55
- strategies.each_with_index do |strategy, index|
56
- run_attempt_for(strategy:, next_strategy: strategies[index + 1], state:)
57
- break if state.fetch(:result)
87
+ AttemptState.new.tap do |state|
88
+ strategies.each_with_index do |strategy, index|
89
+ attempt(strategy:, next_strategy: strategies[index + 1], state:)
90
+ break if state.succeeded?
91
+ end
58
92
  end
59
- [state.fetch(:result), state.fetch(:attempts)]
60
- end
61
-
62
- def run_attempt_for(strategy:, next_strategy:, state:)
63
- result, attempts = attempt(
64
- strategy:,
65
- next_strategy:,
66
- state: { attempts: state.fetch(:attempts) }
67
- )
68
- state[:result] = result
69
- state[:attempts] = attempts
70
93
  end
71
94
 
72
95
  def attempt(strategy:, next_strategy:, state:)
73
96
  request_session = session_for.call(strategy:, budget:)
74
- response, state = fetch_response(
75
- request_session:,
76
- strategy:,
77
- next_strategy:,
78
- state:
79
- )
80
- return [nil, state.fetch(:attempts)] unless response
97
+ response = fetch_response(request_session:, strategy:, next_strategy:, state:)
98
+ return unless response
81
99
 
82
100
  process_response(response:, strategy:, next_strategy:, request_session:, state:)
83
101
  end
84
102
 
85
103
  def fetch_response(request_session:, strategy:, next_strategy:, state:)
86
- [request_session.fetch_initial_response, state]
104
+ request_session.fetch_initial_response
87
105
  rescue *NON_FALLBACK_ERRORS
88
106
  raise
89
107
  rescue StandardError => error
90
- state[:attempts] << { strategy:, items_count: nil, error_class: error.class.name }
108
+ state.record_error(strategy:, error:)
91
109
  log_warn_fallback_error(strategy:, next_strategy:, error:) if next_strategy
92
110
  Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message}")
93
- [nil, state]
111
+ nil
94
112
  end
95
113
 
96
114
  def process_response(response:, strategy:, next_strategy:, request_session:, state:)
97
115
  articles = articles_for.call(response:, request_session:)
98
116
  items_count = articles.size
99
- state[:attempts] << { strategy:, items_count:, error_class: nil }
117
+ state.record_items(strategy:, items_count:)
100
118
  Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count}")
101
- return success_state(response:, strategy:, articles:, state:) if items_count.positive?
119
+ return record_success(response:, strategy:, articles:, state:) if items_count.positive?
102
120
 
103
121
  log_info_fallback_zero_items(strategy:, next_strategy:) if next_strategy
104
- [nil, state.fetch(:attempts)]
105
122
  end
106
123
 
107
- def success_state(response:, strategy:, articles:, state:)
108
- if state.fetch(:attempts).size > 1
109
- Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{state.fetch(:attempts).size}")
110
- end
111
- [{ response:, articles: }, state.fetch(:attempts)]
124
+ def record_success(response:, strategy:, articles:, state:)
125
+ state.succeed!(response:, articles:)
126
+ return unless state.attempts.size > 1
127
+
128
+ Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{state.attempts.size}")
112
129
  end
113
130
 
114
131
  def finalize_failure(attempts:)
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class FeedPipeline
5
+ ##
6
+ # Planner for the runtime request policy and budgets of a feed run.
7
+ #
8
+ # HTTP request slots (pagination, document GETs, strategy fallback) are planned
9
+ # separately from Browserless preload interaction budget so preload cannot steal
10
+ # pagination slots.
11
+ class RuntimePolicy
12
+ # Policy plus Budget from one config expansion (shared meters for a feed run).
13
+ Resources = Data.define(:policy, :budget)
14
+
15
+ ##
16
+ # @param config [Html2rss::Config] validated feed config
17
+ # @return [Html2rss::RequestService::Policy] request policy derived from runtime config
18
+ def self.from_config(config)
19
+ RequestService::Policy.new(
20
+ max_requests: effective_max_requests_for(config),
21
+ max_redirects: config.max_redirects,
22
+ total_timeout_seconds: config.total_timeout_seconds || RequestService::Policy::DEFAULTS[:total_timeout_seconds]
23
+ )
24
+ end
25
+
26
+ ##
27
+ # Builds policy and budget from one `from_config` so FeedPipeline does not dual-own expansion.
28
+ #
29
+ # @param config [Html2rss::Config] validated feed config
30
+ # @return [Resources] shared policy + budget for the feed build
31
+ def self.resources_for(config)
32
+ policy = from_config(config)
33
+ Resources.new(
34
+ policy:,
35
+ budget: RequestService::Budget.new(
36
+ max_requests: policy.max_requests,
37
+ max_interactions: interaction_budget_for(config),
38
+ total_timeout_seconds: policy.total_timeout_seconds
39
+ )
40
+ )
41
+ end
42
+
43
+ ##
44
+ # Builds a Budget with separate request and interaction slot pools.
45
+ #
46
+ # @param config [Html2rss::Config] validated feed config
47
+ # @return [Html2rss::RequestService::Budget] shared budget for the feed build
48
+ def self.budget_for(config) = resources_for(config).budget
49
+
50
+ ##
51
+ # @param config [Html2rss::Config] validated feed config
52
+ # @return [Integer] preload interaction slots derived from browserless preload config
53
+ def self.interaction_budget_for(config)
54
+ browserless_preload_budget_for(config)
55
+ end
56
+
57
+ class << self
58
+ private
59
+
60
+ def effective_max_requests_for(config)
61
+ return config.max_requests if config.explicit_max_requests?
62
+
63
+ [baseline_request_budget_for(config), config.max_requests].max
64
+ end
65
+
66
+ # Reserve enough HTTP request slots for the initial request plus predictable
67
+ # follow-ups. Preload interactions are planned separately.
68
+ def baseline_request_budget_for(config)
69
+ 1 +
70
+ RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
71
+ AutoSource.request_slots_for(config.auto_source) +
72
+ StrategyPlan.resolve(config.strategy).request_slots
73
+ end
74
+
75
+ def browserless_preload_budget_for(config)
76
+ preload = config.request.dig(:browserless, :preload)
77
+ return 0 unless preload
78
+
79
+ top_level_preload_wait_budget(preload) +
80
+ click_selector_preload_budget(preload) +
81
+ scroll_preload_budget(preload)
82
+ end
83
+
84
+ def top_level_preload_wait_budget(preload)
85
+ preload[:wait_after_ms] ? 2 : 0
86
+ end
87
+
88
+ def click_selector_preload_budget(preload)
89
+ preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
90
+ end
91
+
92
+ def scroll_preload_budget(preload)
93
+ scroll = preload[:scroll_down]
94
+ return 0 unless scroll
95
+
96
+ preload_action_budget(scroll, :iterations)
97
+ end
98
+
99
+ def preload_action_budget(config, count_key)
100
+ action_count = config.fetch(count_key, 1)
101
+ wait_budget = config[:wait_after_ms] ? action_count : 0
102
+
103
+ action_count + wait_budget
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class FeedPipeline
5
+ ##
6
+ # Feed-level request plan: +:auto+ (fallback chain) or a concrete transport strategy.
7
+ #
8
+ # {RequestService} executes concrete adapters only. +:auto+ is resolved here before
9
+ # any session or adapter call — plan vs transport locality.
10
+ class StrategyPlan
11
+ # Feed-level auto plan: try {AutoFallback::CHAIN} until items are extracted.
12
+ Auto = Data.define do
13
+ # @return [Integer]
14
+ def request_slots
15
+ [AutoFallback::CHAIN.size - 1, 0].max
16
+ end
17
+ end
18
+
19
+ # Concrete transport strategy name for {RequestService}.
20
+ Concrete = Data.define(:strategy) do
21
+ # @return [Integer]
22
+ def request_slots = 0
23
+ end
24
+
25
+ # Symbol used in config / configure for the auto plan.
26
+ AUTO_NAME = :auto
27
+
28
+ class << self
29
+ ##
30
+ # @param name [Symbol, String] config or configure strategy value
31
+ # @return [Auto, Concrete] resolved feed-level plan
32
+ # @raise [ArgumentError] when +name+ is neither +:auto+ nor a registered strategy
33
+ def resolve(name)
34
+ normalized = name.to_sym
35
+ return Auto.new if normalized == AUTO_NAME
36
+
37
+ unless RequestService.strategy_registered?(normalized)
38
+ raise ArgumentError, "unknown strategy plan: #{name.inspect}"
39
+ end
40
+
41
+ Concrete.new(strategy: normalized)
42
+ end
43
+
44
+ ##
45
+ # @param name [Symbol, String, nil] candidate plan name
46
+ # @return [Boolean] whether +name+ is a valid feed-level strategy plan
47
+ def valid?(name)
48
+ return false unless name.is_a?(Symbol) || name.is_a?(String)
49
+
50
+ resolve(name)
51
+ true
52
+ rescue ArgumentError
53
+ false
54
+ end
55
+
56
+ ##
57
+ # @return [Array<Symbol>] accepted plan names (+:auto+ plus registered strategies)
58
+ def accepted_names
59
+ [AUTO_NAME, *RequestService.strategy_names.map(&:to_sym)].uniq
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end