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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module Rendering
6
+ # Builds a sanitized article description from the base text, title, and optional media.
7
+ #
8
+ # Combines media elements (images, audio, video, PDFs) with sanitized text content
9
+ # to create rich RSS descriptions that reveal more scraped information.
10
+ #
11
+ # @example Basic usage
12
+ # builder = DescriptionBuilder.new(
13
+ # base: "Article content",
14
+ # title: "Article Title",
15
+ # url: "https://example.com",
16
+ # enclosures: [enclosure_object],
17
+ # image: "https://example.com/image.jpg"
18
+ # )
19
+ # description = builder.call
20
+ class DescriptionBuilder
21
+ # Removes the specified pattern from the beginning of the text
22
+ # within a given range if the pattern occurs before the range's end.
23
+ #
24
+ # @param text [String]
25
+ # @param pattern [String]
26
+ # @param end_of_range [Integer] Optional, defaults to half the text length
27
+ # @return [String]
28
+ def self.remove_pattern_from_start(text, pattern, end_of_range: (text.size * 0.5).to_i)
29
+ return text unless text.is_a?(String) && pattern.is_a?(String) && !pattern.empty?
30
+
31
+ index = text.index(pattern)
32
+ return text if index.nil? || index > end_of_range
33
+
34
+ "#{text[0, index]}#{text[(index + pattern.size)..]}"
35
+ end
36
+
37
+ # @param base [String] The base text content for the description
38
+ # @param title [String] The article title (used for alt text and title removal)
39
+ # @param url [String, Html2rss::Url] The article URL (used for sanitization)
40
+ # @param enclosures [Array<Html2rss::Article::Enclosure>, nil] Media enclosures
41
+ # @param image [String, Html2rss::Url, nil] Fallback image URL
42
+ def initialize(base:, title:, url:, enclosures:, image:)
43
+ @base = base.to_s
44
+ @title = title
45
+ @url = url
46
+ @enclosures = Array(enclosures)
47
+ @image = image
48
+ end
49
+
50
+ # Generates the complete description with media and sanitized text.
51
+ #
52
+ # @return [String, nil] The complete description or nil if empty
53
+ def call
54
+ fragments = []
55
+ fragments.concat(Array(rendered_media))
56
+ fragments << processed_base_description
57
+
58
+ result = fragments.compact.join("\n\n").strip
59
+ result.empty? ? nil : result
60
+ end
61
+
62
+ private
63
+
64
+ def rendered_media
65
+ rendered = render_enclosures
66
+ return rendered if rendered.any?
67
+ return render_fallback_image if @image
68
+
69
+ []
70
+ end
71
+
72
+ def render_enclosures
73
+ @enclosures.filter_map do |enclosure|
74
+ MediaRenderer.for(enclosure:, image: @image, title: @title)&.to_html
75
+ end
76
+ end
77
+
78
+ def render_fallback_image
79
+ [MediaRenderer.for(enclosure: nil, image: @image, title: @title)&.to_html]
80
+ end
81
+
82
+ def processed_base_description
83
+ text = self.class.remove_pattern_from_start(@base, @title)
84
+ Html2rss::Selectors::PostProcessors::SanitizeHtml.get(text, @url)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ # Renders an HTML <img> tag from a URL and title.
9
+ class ImageRenderer
10
+ # @param url [String, Html2rss::Url] image URL for the src attribute
11
+ # @param title [String, nil] title/alt text for the image
12
+ def initialize(url:, title:)
13
+ @url = url
14
+ @title = title
15
+ end
16
+
17
+ # @return [String] HTML image snippet for article rendering
18
+ def to_html
19
+ attributes = [
20
+ %(src="#{escaped_url}"),
21
+ %(alt="#{escaped_title}"),
22
+ %(title="#{escaped_title}"),
23
+ 'loading="lazy"',
24
+ 'referrerpolicy="no-referrer"',
25
+ 'decoding="async"',
26
+ 'crossorigin="anonymous"'
27
+ ]
28
+ "<img #{attributes.join(' ')}>"
29
+ end
30
+
31
+ private
32
+
33
+ def escaped_url
34
+ CGI.escapeHTML(@url.to_s)
35
+ end
36
+
37
+ def escaped_title
38
+ CGI.escapeHTML(@title.to_s)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module Rendering
6
+ # Factory: picks the appropriate renderer for a given enclosure or fallback image.
7
+ class MediaRenderer
8
+ # @param enclosure [Html2rss::Article::Enclosure, nil]
9
+ # @param image [String, Html2rss::Url, nil] Fallback image URL
10
+ # @param title [String]
11
+ # @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
12
+ def self.for(enclosure:, image:, title:)
13
+ return ImageRenderer.new(url: image, title:) if enclosure.nil? && image
14
+ return nil unless enclosure
15
+
16
+ create_renderer_for_type(enclosure.type, url: enclosure.url, title:)
17
+ end
18
+
19
+ # @private
20
+ # @param type [String, nil] enclosure MIME type
21
+ # @param url [String, Html2rss::Url] enclosure URL
22
+ # @param title [String, nil] title used by image renderer
23
+ # @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
24
+ def self.create_renderer_for_type(type, url:, title:)
25
+ case type
26
+ when %r{^image/}
27
+ ImageRenderer.new(url:, title:)
28
+ when %r{^video/}
29
+ VideoRenderer.new(url:, type:)
30
+ when %r{^audio/}
31
+ AudioRenderer.new(url:, type:)
32
+ when 'application/pdf'
33
+ PdfRenderer.new(url:)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ # Renders an HTML <iframe> for PDF documents.
9
+ class PdfRenderer
10
+ # @param url [String, Html2rss::Url] PDF URL rendered in the iframe
11
+ def initialize(url:)
12
+ @url = url
13
+ end
14
+
15
+ # @return [String] HTML iframe snippet for PDF rendering
16
+ def to_html
17
+ attributes = [
18
+ %(src="#{escaped_url}"),
19
+ 'width="100%"',
20
+ 'height="75vh"',
21
+ 'sandbox=""',
22
+ 'referrerpolicy="no-referrer"',
23
+ 'loading="lazy"'
24
+ ]
25
+ "<iframe #{attributes.join(' ')}></iframe>"
26
+ end
27
+
28
+ private
29
+
30
+ def escaped_url
31
+ CGI.escapeHTML(@url.to_s)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ # Renders an HTML <video> tag from a URL and type.
9
+ class VideoRenderer
10
+ # @param url [String, Html2rss::Url] media URL for the video source
11
+ # @param type [String] MIME type for the video source
12
+ def initialize(url:, type:)
13
+ @url = url
14
+ @type = type
15
+ end
16
+
17
+ # @return [String] HTML video snippet for article rendering
18
+ def to_html
19
+ [
20
+ '<video controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous" playsinline>',
21
+ %(<source src="#{escaped_url}" type="#{escaped_type}">),
22
+ '</video>'
23
+ ].join
24
+ end
25
+
26
+ private
27
+
28
+ def escaped_url
29
+ CGI.escapeHTML(@url.to_s)
30
+ end
31
+
32
+ def escaped_type
33
+ CGI.escapeHTML(@type.to_s)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ # Namespace for HTML rendering logic, used to generate rich content such as
6
+ # images, audio, video, or embedded documents for feed descriptions.
7
+ #
8
+ # @see Html2rss::Html::Rendering::DescriptionBuilder
9
+ #
10
+ # @example
11
+ # Html2rss::Html::Rendering::ImageRenderer.new(
12
+ # url: "https://example.com/image.jpg",
13
+ # title: "Example"
14
+ # ).to_html
15
+ #
16
+ # @example
17
+ # Html2rss::Html::Rendering::MediaRenderer.for(
18
+ # enclosure: nil,
19
+ # image: "https://example.com/image.jpg",
20
+ # title: "Example"
21
+ # )
22
+ module Rendering
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # DOM helpers for navigating and extracting from HTML trees.
6
+ module Html
7
+ end
8
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ ##
6
+ # Shared anti-bot/interstitial signatures used by request and auto-source flows.
7
+ #
8
+ # This module centralizes signature matching so request-time guards and
9
+ # auto-source surface classification stay consistent.
10
+ module BlockedSurface
11
+ # Known interstitial fingerprints used to detect blocked or anti-bot surfaces.
12
+ INTERSTITIAL_SIGNATURES = [
13
+ {
14
+ key: :cloudflare_interstitial,
15
+ min_matches: 2,
16
+ patterns: [
17
+ %r{<title>\s*just a moment\.\.\.\s*</title>}i,
18
+ /checking your browser before accessing/i,
19
+ /please (?:enable|turn on) javascript and cookies/i,
20
+ %r{cdn-cgi/challenge-platform}i,
21
+ /cloudflare ray id/i
22
+ ],
23
+ message: 'Blocked surface detected: Cloudflare anti-bot interstitial page. ' \
24
+ 'Retry with --strategy browserless, try a more specific public listing URL, ' \
25
+ 'or run from an environment that can complete anti-bot checks.'
26
+ }
27
+ ].freeze
28
+
29
+ ##
30
+ # Returns the first matching interstitial signature for the provided body.
31
+ #
32
+ # @param body [String, nil] response body candidate
33
+ # @return [Hash, nil] signature hash when matched, otherwise nil
34
+ def self.interstitial_signature_for(body)
35
+ normalized_body = normalize_body(body)
36
+ INTERSTITIAL_SIGNATURES.find { |signature| interstitial_signature_match?(normalized_body, signature) }
37
+ end
38
+
39
+ ##
40
+ # @param body [String, nil] response body candidate
41
+ # @return [Boolean] true when body matches a known interstitial signature
42
+ def self.interstitial?(body)
43
+ !interstitial_signature_for(body).nil?
44
+ end
45
+
46
+ def self.interstitial_signature_match?(body, signature)
47
+ min_matches = signature.fetch(:min_matches, 1)
48
+ matches = 0
49
+
50
+ signature.fetch(:patterns).each do |pattern|
51
+ matches += 1 if pattern.match?(body)
52
+ return true if matches >= min_matches
53
+ end
54
+
55
+ false
56
+ end
57
+ private_class_method :interstitial_signature_match?
58
+
59
+ def self.normalize_body(body)
60
+ body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
61
+ rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
62
+ body.to_s.force_encoding(Encoding::UTF_8).scrub
63
+ end
64
+ private_class_method :normalize_body
65
+ end
66
+ end
67
+ end
@@ -2,11 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
 
5
- ##
6
- # Main html2rss namespace.
7
5
  module Html2rss
8
- ##
9
- # Request transport orchestration and strategies.
10
6
  class RequestService
11
7
  ##
12
8
  # Maps html2rss request/response handling to the botasaurus-scrape-api contract.
@@ -14,7 +10,7 @@ module Html2rss
14
10
  # Default Botasaurus scrape options when no explicit config is provided.
15
11
  DEFAULT_OPTIONS = {
16
12
  navigation_mode: 'auto',
17
- max_retries: 2,
13
+ max_retries: 1,
18
14
  headless: false
19
15
  }.freeze
20
16
 
@@ -34,6 +30,17 @@ module Html2rss
34
30
  lang
35
31
  ].freeze
36
32
 
33
+ # Allowlisted upstream response keys exposed as Response#transport_meta.
34
+ META_KEYS = %w[
35
+ request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
36
+ ].freeze
37
+
38
+ # Remaining seconds at or below which Botasaurus retries are disabled.
39
+ TIGHT_BUDGET_SECONDS = 12
40
+
41
+ # Seconds reserved from remaining budget before setting wait_timeout_seconds.
42
+ BUDGET_WAIT_RESERVE_SECONDS = 2
43
+
37
44
  # Parsed Botasaurus response wrapper.
38
45
  class ParsedResponse
39
46
  # Fallback headers when upstream omits response headers.
@@ -94,6 +101,9 @@ module Html2rss
94
101
  # @return [String, nil] final URL reported by upstream
95
102
  def final_url = payload['final_url']
96
103
 
104
+ # @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
105
+ def transport_meta = payload.slice(*META_KEYS).compact.freeze
106
+
97
107
  private
98
108
 
99
109
  attr_reader :payload, :transport_status
@@ -113,6 +123,7 @@ module Html2rss
113
123
  ##
114
124
  # @param url [Html2rss::Url] canonical URL to scrape
115
125
  # @param options [Hash] validated request.botasaurus options
126
+ # @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
116
127
  # @option options [String] :navigation_mode
117
128
  # @option options [Integer] :max_retries
118
129
  # @option options [String] :wait_for_selector
@@ -125,14 +136,15 @@ module Html2rss
125
136
  # @option options [String] :user_agent
126
137
  # @option options [Array<Integer>] :window_size
127
138
  # @option options [String] :lang
128
- def initialize(url:, options: {})
139
+ def initialize(url:, options: {}, remaining_timeout_seconds: nil)
129
140
  @url = url
130
141
  @options = options
142
+ @remaining_timeout_seconds = remaining_timeout_seconds
131
143
  end
132
144
 
133
- # @return [Hash] payload for POST /scrape
145
+ # @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
134
146
  def request_payload
135
- DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s)
147
+ DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s).then { clamp_for_budget(_1) }
136
148
  end
137
149
 
138
150
  # @param transport_response [Faraday::Response] upstream HTTP response
@@ -149,13 +161,25 @@ module Html2rss
149
161
 
150
162
  private
151
163
 
152
- attr_reader :url, :options
164
+ attr_reader :url, :options, :remaining_timeout_seconds
153
165
 
154
166
  def filtered_options
155
167
  OPTION_KEYS.each_with_object({}) do |key, normalized|
156
168
  normalized[key] = options[key] if options.key?(key)
157
169
  end
158
170
  end
171
+
172
+ def clamp_for_budget(payload)
173
+ remaining = remaining_timeout_seconds
174
+ return payload if remaining.nil?
175
+
176
+ clamped = payload.dup
177
+ clamped[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
178
+ budget_wait = [1, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
179
+ configured = clamped[:wait_timeout_seconds]
180
+ clamped[:wait_timeout_seconds] = configured ? [configured, budget_wait].min : budget_wait
181
+ clamped
182
+ end
159
183
  end
160
184
  end
161
185
  end
@@ -8,47 +8,27 @@ module Html2rss
8
8
  ##
9
9
  # Strategy to delegate fetching to a Botasaurus scrape API.
10
10
  class BotasaurusStrategy < Strategy
11
- ##
12
- # Executes a Botasaurus-backed request with shared request policy guards.
13
- #
14
- # @return [Response] normalized request response
15
- # @raise [BotasaurusConfigurationError] when BOTASAURUS_SCRAPER_URL is missing or invalid
16
- # @raise [BotasaurusConnectionFailed] when Botasaurus cannot be reached or returns an invalid payload
17
- # @raise [RequestTimedOut] when the Botasaurus request exceeds configured timeout
18
- def execute
19
- check_timeout!
20
- validate_request!
11
+ private
12
+
13
+ def fetch
21
14
  parsed_response = post_scrape_request
22
15
  raise_if_challenge_blocked!(parsed_response)
23
16
  raise_if_upstream_failed!(parsed_response)
24
17
  build_response(parsed_response)
25
- rescue Faraday::TimeoutError, Timeout::Error => error
26
- raise RequestTimedOut, error.message
27
- rescue Faraday::ConnectionFailed, Faraday::SSLError => error
28
- raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
29
18
  end
30
19
 
31
- private
32
-
33
20
  def post_scrape_request
34
21
  transport_response = client.post('/scrape', JSON.generate(contract.request_payload), content_type_header)
35
22
  contract.parse_response(transport_response)
36
23
  end
37
24
 
38
- def validate_request!
39
- ctx.budget.consume!
40
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
41
- end
42
-
43
25
  def build_response(parsed_response)
44
- body = parsed_response.html
45
- ResponseGuard.new(policy: ctx.policy).inspect_body!(body)
46
-
47
26
  Response.new(
48
- body:,
27
+ body: parsed_response.html,
49
28
  headers: parsed_response.headers,
50
29
  url: response_url(parsed_response.final_url),
51
- status: parsed_response.status
30
+ status: parsed_response.status,
31
+ transport_meta: parsed_response.transport_meta
52
32
  )
53
33
  end
54
34
 
@@ -73,7 +53,11 @@ module Html2rss
73
53
  end
74
54
 
75
55
  def contract
76
- @contract ||= BotasaurusContract.new(url: ctx.url, options: ctx.request.fetch(:botasaurus, {}))
56
+ @contract ||= BotasaurusContract.new(
57
+ url: ctx.url,
58
+ options: ctx.request.fetch(:botasaurus, {}),
59
+ remaining_timeout_seconds: attempt_timeout_seconds
60
+ )
77
61
  end
78
62
 
79
63
  def client
@@ -81,10 +65,13 @@ module Html2rss
81
65
  end
82
66
 
83
67
  def request_options
84
- timeout = ctx.budget.remaining_timeout_seconds || ctx.policy.total_timeout_seconds
85
- raise RequestTimedOut, 'Request timed out' if timeout <= 0
68
+ { timeout: attempt_timeout_seconds.to_i }
69
+ end
86
70
 
87
- { timeout: timeout.to_i }
71
+ def attempt_timeout_seconds
72
+ @attempt_timeout_seconds ||= ctx.budget.effective_timeout_seconds(
73
+ fallback: ctx.policy.total_timeout_seconds
74
+ )
88
75
  end
89
76
 
90
77
  def content_type_header
@@ -101,6 +88,10 @@ module Html2rss
101
88
  raise BotasaurusConfigurationError, "BOTASAURUS_SCRAPER_URL is invalid: #{error.message}"
102
89
  end
103
90
  end
91
+
92
+ def translate_connection_error(error)
93
+ raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
94
+ end
104
95
  end
105
96
  end
106
97
  end
@@ -31,19 +31,6 @@ module Html2rss
31
31
  # are aligned with the default values.
32
32
  # @see https://github.com/browserless/browserless/pkgs/container/chromium
33
33
  class BrowserlessStrategy < Strategy
34
- ##
35
- # Executes a Browserless-backed request with the shared request policy.
36
- #
37
- # @return [Response] normalized request response
38
- # @raise [RequestTimedOut] if the browser session exceeds the configured timeout
39
- def execute
40
- check_timeout!
41
- validate_request!
42
- execute_browserless_request
43
- rescue Puppeteer::TimeoutError => error
44
- raise RequestTimedOut, error.message
45
- end
46
-
47
34
  ##
48
35
  # @return [String] the Browserless websocket endpoint with token query param
49
36
  # @raise [ArgumentError] if a custom endpoint is configured without an API token
@@ -58,12 +45,7 @@ module Html2rss
58
45
 
59
46
  private
60
47
 
61
- def validate_request!
62
- ctx.budget.consume!
63
- ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
64
- end
65
-
66
- def execute_browserless_request
48
+ def fetch
67
49
  connect_with_timeout_support do |browser|
68
50
  PuppetCommander.new(ctx, browser).call
69
51
  ensure
@@ -72,10 +54,7 @@ module Html2rss
72
54
  end
73
55
 
74
56
  def protocol_timeout_ms
75
- timeout = ctx.budget.remaining_timeout_seconds || ctx.policy.total_timeout_seconds
76
- raise RequestTimedOut, 'Request timed out' if timeout <= 0
77
-
78
- (timeout * 1000).to_i
57
+ ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
79
58
  end
80
59
 
81
60
  def connect_with_timeout_support(&)