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.
Files changed (146) 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 +213 -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/category_extractor.rb +29 -114
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
  29. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  30. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  32. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  33. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  35. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  36. data/lib/html2rss/auto_source/scraper.rb +10 -3
  37. data/lib/html2rss/auto_source.rb +30 -35
  38. data/lib/html2rss/channel.rb +198 -0
  39. data/lib/html2rss/cli.rb +5 -6
  40. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  41. data/lib/html2rss/config/request_controls.rb +136 -0
  42. data/lib/html2rss/config/request_headers.rb +1 -1
  43. data/lib/html2rss/config/schema.rb +65 -114
  44. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  45. data/lib/html2rss/config/validator.rb +6 -6
  46. data/lib/html2rss/config.rb +176 -19
  47. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  48. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  49. data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
  50. data/lib/html2rss/feed_builder/json_feed.rb +81 -0
  51. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  52. data/lib/html2rss/feed_builder/rss.rb +116 -0
  53. data/lib/html2rss/feed_builder.rb +15 -0
  54. data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
  55. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  56. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  57. data/lib/html2rss/feed_pipeline.rb +83 -89
  58. data/lib/html2rss/feed_result.rb +92 -0
  59. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  60. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  61. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  62. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  63. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  64. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  65. data/lib/html2rss/html/article_extractor.rb +134 -0
  66. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  67. data/lib/html2rss/html/navigator.rb +146 -0
  68. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  70. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  71. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  72. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  73. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  74. data/lib/html2rss/html/rendering.rb +25 -0
  75. data/lib/html2rss/html.rb +8 -0
  76. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  77. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
  78. data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
  79. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  80. data/lib/html2rss/request_service/budget.rb +97 -18
  81. data/lib/html2rss/request_service/context.rb +1 -3
  82. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  83. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  84. data/lib/html2rss/request_service/network_guard.rb +125 -0
  85. data/lib/html2rss/request_service/policy.rb +10 -82
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  88. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  89. data/lib/html2rss/request_service/response.rb +9 -1
  90. data/lib/html2rss/request_service/response_guard.rb +1 -1
  91. data/lib/html2rss/request_service/strategy.rb +123 -5
  92. data/lib/html2rss/request_service.rb +7 -2
  93. data/lib/html2rss/request_session/pager/base.rb +83 -0
  94. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  95. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  96. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  97. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  98. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  99. data/lib/html2rss/request_session/pager.rb +75 -0
  100. data/lib/html2rss/request_session.rb +29 -16
  101. data/lib/html2rss/selectors/item_scope.rb +30 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  104. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  105. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  106. data/lib/html2rss/selectors.rb +84 -64
  107. data/lib/html2rss/status.rb +132 -0
  108. data/lib/html2rss/version.rb +1 -3
  109. data/lib/html2rss.rb +34 -22
  110. data/schema/html2rss-config.schema.json +109 -9
  111. metadata +71 -43
  112. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  113. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  114. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
  115. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  116. data/lib/html2rss/blocked_surface.rb +0 -65
  117. data/lib/html2rss/category_extractor.rb +0 -116
  118. data/lib/html2rss/config/class_methods.rb +0 -183
  119. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  120. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  121. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  122. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  123. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  124. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  125. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  126. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  127. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  128. data/lib/html2rss/html_extractor.rb +0 -190
  129. data/lib/html2rss/html_navigator.rb +0 -71
  130. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  131. data/lib/html2rss/json_feed_builder.rb +0 -59
  132. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  133. data/lib/html2rss/rendering/description_builder.rb +0 -87
  134. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  135. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  136. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  137. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  138. data/lib/html2rss/rendering.rb +0 -23
  139. data/lib/html2rss/request_controls.rb +0 -133
  140. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  141. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  142. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  143. data/lib/html2rss/rss_builder/article.rb +0 -208
  144. data/lib/html2rss/rss_builder/channel.rb +0 -118
  145. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  146. data/lib/html2rss/rss_builder.rb +0 -101
@@ -0,0 +1,112 @@
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
+ # Title is a fallback only when description is absent.
54
+ #
55
+ # @return [Hash]
56
+ def content_fields
57
+ if (description = ItemPresentation.description_for(article))
58
+ return content_for(description)
59
+ end
60
+
61
+ return { content_text: article.title } if article.title
62
+
63
+ {}
64
+ end
65
+
66
+ ##
67
+ # @param description [String]
68
+ # @return [Hash]
69
+ def content_for(description)
70
+ html?(description) ? { content_html: description } : { content_text: description }
71
+ end
72
+
73
+ ##
74
+ # @param value [String]
75
+ # @return [Boolean]
76
+ def html?(value)
77
+ Nokogiri::HTML.fragment(value).children.any?(&:element?)
78
+ end
79
+
80
+ ##
81
+ # @return [Array<String>, nil]
82
+ def tags
83
+ cats = article.categories
84
+ cats.empty? ? nil : cats
85
+ end
86
+
87
+ ##
88
+ # Maps enclosures to JSONFeed attachment objects.
89
+ # @return [Array<Hash>, nil]
90
+ def attachments
91
+ enclosures = article.enclosures
92
+ return nil if enclosures.empty?
93
+
94
+ enclosures.map { |enc| attachment_hash(enc) }
95
+ end
96
+
97
+ ##
98
+ # @param enclosure [Html2rss::Article::Enclosure]
99
+ # @return [Hash]
100
+ def attachment_hash(enclosure)
101
+ size = enclosure.bytes_length
102
+
103
+ {
104
+ url: enclosure.url.to_s,
105
+ mime_type: enclosure.type,
106
+ size_in_bytes: size&.positive? ? size : nil
107
+ }.compact
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,81 @@
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
+ # @param user_comment [String] required generator comment (from {Status})
17
+ # @param feed_url [String, nil] optional absolute self URL for the feed (JSON Feed feed_url)
18
+ def initialize(channel:, articles:, user_comment:, feed_url: nil)
19
+ raise ArgumentError, 'user_comment must be a non-blank String' if blank_string?(user_comment)
20
+
21
+ @channel = channel
22
+ @articles = articles
23
+ @feed_url = normalize_feed_url(feed_url)
24
+ @user_comment = user_comment
25
+ end
26
+
27
+ ##
28
+ # Builds and returns the JSONFeed hash.
29
+ #
30
+ # @return [Hash] the JSONFeed-compliant hash
31
+ def call
32
+ base_payload.merge(authors: author_array, items: item_hashes).compact
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :channel, :articles, :feed_url, :user_comment
38
+
39
+ def blank_string?(value)
40
+ !value.is_a?(String) || value.strip.empty?
41
+ end
42
+
43
+ def normalize_feed_url(value)
44
+ return if value.nil?
45
+ raise ArgumentError, 'feed_url must be a String or nil' unless value.is_a?(String)
46
+ return if value.strip.empty?
47
+
48
+ Url.from_absolute(value).to_s
49
+ end
50
+
51
+ ##
52
+ # @return [Hash]
53
+ def base_payload
54
+ {
55
+ version: VERSION_URL,
56
+ title: channel.title,
57
+ home_page_url: channel.url.to_s,
58
+ feed_url:,
59
+ description: channel.description,
60
+ user_comment:,
61
+ language: channel.language,
62
+ icon: channel.image&.to_s
63
+ }
64
+ end
65
+
66
+ ##
67
+ # @return [Array<Hash>]
68
+ def item_hashes
69
+ articles.filter_map { |article| Item.new(article).to_h }
70
+ end
71
+
72
+ ##
73
+ # @return [Array<Hash>, nil]
74
+ def author_array
75
+ return unless (name = channel.author)
76
+
77
+ [{ name: }]
78
+ end
79
+ end
80
+ end
81
+ 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(ItemPresentation.rss_enclosure_for(article), 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
+ {
38
+ title: article.title,
39
+ description: ItemPresentation.description_for(article),
40
+ author: article.author
41
+ }.each do |attr, value|
42
+ next if value.nil? || value.empty?
43
+
44
+ item_maker.public_send(:"#{attr}=", value)
45
+ end
46
+
47
+ item_maker.link = article.url.to_s if article.url
48
+ item_maker.pubDate = article.published_at&.rfc2822
49
+ end
50
+
51
+ def add_item_categories(article, item_maker)
52
+ article.categories.each { |category| item_maker.categories.new_category.content = category }
53
+ end
54
+
55
+ def add_item_guid(article, item_maker)
56
+ item_maker.guid.tap do |guid|
57
+ guid.content = article.guid
58
+ guid.isPermaLink = false
59
+ end
60
+ end
61
+ end
62
+
63
+ ##
64
+ # @param channel [Html2rss::Channel] The channel information for the RSS feed.
65
+ # @param articles [Array<Html2rss::Article>] The list of articles to include in the RSS feed.
66
+ # @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
67
+ # @param generator [String] required preformatted generator comment (from {Status})
68
+ def initialize(channel:, articles:, generator:, stylesheets: [])
69
+ raise ArgumentError, 'generator must be a non-blank String' if blank_string?(generator)
70
+
71
+ @channel = channel
72
+ @articles = articles
73
+ @stylesheets = stylesheets
74
+ @generator = generator
75
+ end
76
+
77
+ # @return [RSS::Rss] RSS 2.0 document instance
78
+ def call
79
+ RSS::Maker.make('2.0') do |maker|
80
+ Stylesheet.add(maker, stylesheets)
81
+
82
+ make_channel(maker.channel)
83
+ make_items(maker)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :channel, :articles, :generator
90
+
91
+ def blank_string?(value)
92
+ !value.is_a?(String) || value.strip.empty?
93
+ end
94
+
95
+ def stylesheets
96
+ @stylesheets.map { |style| Stylesheet.new(**style) }
97
+ end
98
+
99
+ def make_channel(maker)
100
+ %i[language title description ttl].each do |key|
101
+ maker.public_send(:"#{key}=", channel.public_send(key))
102
+ end
103
+
104
+ maker.link = channel.url.to_s
105
+ maker.generator = generator
106
+ maker.updated = channel.last_build_date
107
+ end
108
+
109
+ def make_items(maker)
110
+ articles.each do |article|
111
+ maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ # Namespace for feed format adapters (RSS 2.0 / JSON Feed 1.1).
5
+ #
6
+ # {FeedResult} constructs {Rss} / {JsonFeed} directly — there is no dispatcher
7
+ # entrypoint on this module. Stylesheets are a scrape-time artifact carried on
8
+ # FeedResult; +feed_url+ is a render-time JSON Feed-only option.
9
+ #
10
+ # Channel field projection:
11
+ # - Rss: language, title, description, ttl, link, updated
12
+ # - JsonFeed: title, home_page_url, description, language, icon, authors (+ feed_url)
13
+ module FeedBuilder
14
+ end
15
+ end
@@ -1,15 +1,14 @@
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+.
8
+ # Hosted by the pipeline instance: session + extract call back into FeedPipeline.
10
9
  class AutoFallback
11
- # Ordered list of concrete request strategies attempted by auto mode.
12
- CHAIN = %i[faraday botasaurus browserless].freeze
10
+ # Ordered list of concrete request strategies attempted by the :auto plan.
11
+ CHAIN = %i[faraday botasaurus].freeze
13
12
 
14
13
  # Error classes that should abort auto fallback immediately.
15
14
  NON_FALLBACK_ERRORS = [
@@ -24,103 +23,164 @@ module Html2rss
24
23
  RequestService::BrowserlessConfigurationError
25
24
  ].freeze
26
25
 
26
+ ##
27
+ # Mutable run state for one auto-fallback chain: attempts plus selected result.
28
+ class AttemptState
29
+ attr_reader :attempts, :result
30
+
31
+ def initialize
32
+ @attempts = []
33
+ @result = nil
34
+ end
35
+
36
+ # @return [Boolean] true when a strategy already yielded items
37
+ def succeeded? = !result.nil?
38
+
39
+ # @param strategy [Symbol] strategy that raised
40
+ # @param error [Exception] caught error
41
+ # @return [void]
42
+ def record_error(strategy:, error:)
43
+ @attempts << { strategy:, items_count: nil, error_class: error.class.name }
44
+ end
45
+
46
+ # @param strategy [Symbol] strategy that returned a response
47
+ # @param items_count [Integer] extracted article count
48
+ # @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
49
+ # @return [void]
50
+ def record_items(strategy:, items_count:, transport_meta: nil)
51
+ attempt = { strategy:, items_count:, error_class: nil }
52
+ attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
53
+ @attempts << attempt
54
+ end
55
+
56
+ # @param response [RequestService::Response] successful response
57
+ # @param articles [Array] extracted articles
58
+ # @param dedup_dropped [Integer] articles removed by deduplication
59
+ # @param selected_strategy [Symbol] concrete strategy that produced items
60
+ # @param attempt_count [Integer] number of attempts recorded for this chain
61
+ # @return [void]
62
+ def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:)
63
+ @result = PipelineOutcome.new(
64
+ response:,
65
+ articles:,
66
+ dedup_dropped:,
67
+ selected_strategy:,
68
+ attempt_count:,
69
+ strategy_attempts: attempts
70
+ )
71
+ end
72
+ end
73
+
27
74
  ##
28
75
  # @param strategies [Array<Symbol>] ordered concrete strategies for fallback
29
76
  # @param budget [RequestService::Budget] shared request budget across retries
30
- # @param session_for [Proc] request session factory proc
31
- # @param articles_for [Proc] article extraction proc
77
+ # @param pipeline [Html2rss::FeedPipeline] host for session + article extraction
78
+ # @param config [Html2rss::Config] validated feed config
79
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources] budget + policy
32
80
  # @return [void]
33
- def initialize(strategies:, budget:, session_for:, articles_for:)
81
+ def initialize(strategies:, budget:, pipeline:, config:, resources:)
34
82
  @strategies = strategies
35
83
  @budget = budget
36
- @session_for = session_for
37
- @articles_for = articles_for
84
+ @pipeline = pipeline
85
+ @config = config
86
+ @resources = resources
38
87
  end
39
88
 
40
89
  ##
41
- # @return [Hash{Symbol => Object}] pipeline state containing :response and :articles
90
+ # @return [Html2rss::FeedPipeline::PipelineOutcome] scrape-finished state for materialization
42
91
  def call
43
- state, attempts = run_attempts
44
- return state if state
92
+ state = run_attempts
93
+ return state.result if state.succeeded?
45
94
 
46
- finalize_failure(attempts:)
95
+ finalize_failure(attempts: state.attempts)
47
96
  end
48
97
 
49
98
  private
50
99
 
51
- attr_reader :strategies, :budget, :session_for, :articles_for
100
+ attr_reader :strategies, :budget, :pipeline, :config, :resources
52
101
 
53
102
  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)
103
+ AttemptState.new.tap do |state|
104
+ strategies.each_with_index do |strategy, index|
105
+ attempt(strategy:, next_strategy: strategies[index + 1], state:)
106
+ break if state.succeeded?
107
+ end
58
108
  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
109
  end
71
110
 
72
111
  def attempt(strategy:, next_strategy:, state:)
73
- 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
112
+ request_session = pipeline.request_session_for(config, strategy:, resources:)
113
+ response = fetch_response(request_session:, strategy:, next_strategy:, state:)
114
+ return unless response
81
115
 
82
116
  process_response(response:, strategy:, next_strategy:, request_session:, state:)
83
117
  end
84
118
 
85
119
  def fetch_response(request_session:, strategy:, next_strategy:, state:)
86
- [request_session.fetch_initial_response, state]
120
+ request_session.fetch_initial_response
87
121
  rescue *NON_FALLBACK_ERRORS
88
122
  raise
89
123
  rescue StandardError => error
90
- state[:attempts] << { strategy:, items_count: nil, error_class: error.class.name }
91
- log_warn_fallback_error(strategy:, next_strategy:, error:) if next_strategy
92
- Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message}")
93
- [nil, state]
124
+ state.record_error(strategy:, error:)
125
+ log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
126
+ nil
94
127
  end
95
128
 
96
129
  def process_response(response:, strategy:, next_strategy:, request_session:, state:)
97
- articles = articles_for.call(response:, request_session:)
130
+ articles, dedup_dropped = articles_for(response:, request_session:)
98
131
  items_count = articles.size
99
- state[:attempts] << { strategy:, items_count:, error_class: nil }
100
- Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count}")
101
- return success_state(response:, strategy:, articles:, state:) if items_count.positive?
132
+ state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
133
+ Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
134
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
135
+ "budget_remaining=#{budget_remaining_label}")
136
+ return record_success(response:, strategy:, articles:, dedup_dropped:, state:) if items_count.positive?
102
137
 
103
- log_info_fallback_zero_items(strategy:, next_strategy:) if next_strategy
104
- [nil, state.fetch(:attempts)]
138
+ log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
105
139
  end
106
140
 
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)]
141
+ def articles_for(response:, request_session:)
142
+ pipeline.deduplicated_articles(config:, response:, request_session:)
143
+ end
144
+
145
+ def record_success(response:, strategy:, articles:, dedup_dropped:, state:)
146
+ attempt_count = state.attempts.size
147
+ state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy, attempt_count:)
148
+ return unless attempt_count > 1
149
+
150
+ Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
151
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
152
+ "budget_remaining=#{budget_remaining_label}")
112
153
  end
113
154
 
114
155
  def finalize_failure(attempts:)
115
156
  raise NoFeedItemsExtracted.new(attempts:)
116
157
  end
117
158
 
118
- def log_warn_fallback_error(strategy:, next_strategy:, error:)
119
- Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after error=#{error.class}")
159
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
160
+ def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
161
+ host = request_session.url.host
162
+ detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
163
+ "budget_remaining=#{budget_remaining_label}"
164
+ if error.is_a?(RequestService::RequestTimedOut)
165
+ Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
166
+ "after timeout=#{error.class} #{detail}")
167
+ else
168
+ Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
169
+ "after error=#{error.class} #{detail}")
170
+ end
171
+ Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
172
+ end
173
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
174
+
175
+ def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
176
+ Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
177
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
178
+ "budget_remaining=#{budget_remaining_label}")
120
179
  end
121
180
 
122
- def log_info_fallback_zero_items(strategy:, next_strategy:)
123
- Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items")
181
+ def budget_remaining_label
182
+ remaining = budget.remaining_timeout_seconds
183
+ remaining.nil? ? 'untracked' : format('%.3f', remaining)
124
184
  end
125
185
  end
126
186
  end