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
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class JsonFeedBuilder
5
- ##
6
- # Maps an {Html2rss::RssBuilder::Article} to a JSONFeed 1.1 item hash.
7
- class Item
8
- ##
9
- # @param article [Html2rss::RssBuilder::Article]
10
- def initialize(article)
11
- @article = article
12
- end
13
-
14
- ##
15
- # @return [Hash, nil] the JSONFeed-compliant item hash
16
- def to_h
17
- content = content_fields
18
- return if content.empty?
19
-
20
- item_payload.merge(content).compact
21
- end
22
-
23
- private
24
-
25
- attr_reader :article
26
-
27
- ##
28
- # @return [Hash]
29
- def item_payload
30
- {
31
- id: article.guid,
32
- url: article.url&.to_s,
33
- title: article.title,
34
- image: article.image&.to_s,
35
- date_published: article.published_at&.iso8601,
36
- authors: author_array,
37
- tags:,
38
- attachments:
39
- }
40
- end
41
-
42
- ##
43
- # @return [Array<Hash>, nil]
44
- def author_array
45
- return unless (name = article.author)
46
-
47
- [{ name: }]
48
- end
49
-
50
- ##
51
- # JSON Feed items must include content_html or content_text.
52
- # @return [Hash]
53
- def content_fields
54
- description = article.description
55
- return { content_html: description } if description
56
-
57
- title = article.title
58
- return { content_text: title } if title
59
-
60
- {}
61
- end
62
-
63
- ##
64
- # @return [Array<String>, nil]
65
- def tags
66
- cats = article.categories
67
- cats.empty? ? nil : cats
68
- end
69
-
70
- ##
71
- # Maps enclosures to JSONFeed attachment objects.
72
- # @return [Array<Hash>, nil]
73
- def attachments
74
- enclosures = article.enclosures
75
- return nil if enclosures.empty?
76
-
77
- enclosures.map { |enc| attachment_hash(enc) }
78
- end
79
-
80
- ##
81
- # @param enclosure [Html2rss::RssBuilder::Article::Enclosure]
82
- # @return [Hash]
83
- def attachment_hash(enclosure)
84
- size = enclosure.bits_length
85
-
86
- {
87
- url: enclosure.url.to_s,
88
- mime_type: enclosure.type,
89
- size_in_bytes: size&.positive? ? size : nil
90
- }.compact
91
- end
92
- end
93
- end
94
- end
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- ##
5
- # Builds a JSONFeed 1.1 hash from channel metadata and articles.
6
- #
7
- # @see https://www.jsonfeed.org/version/1.1/
8
- class JsonFeedBuilder
9
- # Official JSON Feed 1.1 schema version URL.
10
- VERSION_URL = 'https://jsonfeed.org/version/1.1'
11
-
12
- ##
13
- # @param channel [Html2rss::RssBuilder::Channel]
14
- # @param articles [Array<Html2rss::RssBuilder::Article>]
15
- def initialize(channel:, articles:)
16
- @channel = channel
17
- @articles = articles
18
- end
19
-
20
- ##
21
- # Builds and returns the JSONFeed hash.
22
- #
23
- # @return [Hash] the JSONFeed-compliant hash
24
- def call
25
- base_payload.merge(authors: author_array, items: item_hashes).compact
26
- end
27
-
28
- private
29
-
30
- attr_reader :channel, :articles
31
-
32
- ##
33
- # @return [Hash]
34
- def base_payload
35
- {
36
- version: VERSION_URL,
37
- title: channel.title,
38
- home_page_url: channel.url.to_s,
39
- description: channel.description,
40
- language: channel.language,
41
- icon: channel.image&.to_s
42
- }
43
- end
44
-
45
- ##
46
- # @return [Array<Hash>]
47
- def item_hashes
48
- articles.filter_map { |article| Item.new(article).to_h }
49
- end
50
-
51
- ##
52
- # @return [Array<Hash>, nil]
53
- def author_array
54
- return unless (name = channel.author)
55
-
56
- [{ name: }]
57
- end
58
- end
59
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'cgi'
4
-
5
- module Html2rss
6
- module Rendering
7
- # Renders an HTML <audio> tag from a URL and type.
8
- class AudioRenderer
9
- # @param url [String, Html2rss::Url] media URL for the audio source
10
- # @param type [String] MIME type for the audio source
11
- def initialize(url:, type:)
12
- @url = url
13
- @type = type
14
- end
15
-
16
- # @return [String] HTML audio snippet for article rendering
17
- def to_html
18
- [
19
- '<audio controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous">',
20
- %(<source src="#{escaped_url}" type="#{escaped_type}">),
21
- '</audio>'
22
- ].join
23
- end
24
-
25
- private
26
-
27
- def escaped_url
28
- CGI.escapeHTML(@url.to_s)
29
- end
30
-
31
- def escaped_type
32
- CGI.escapeHTML(@type.to_s)
33
- end
34
- end
35
- end
36
- end
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- module Rendering
5
- # Builds a sanitized article description from the base text, title, and optional media.
6
- #
7
- # Combines media elements (images, audio, video, PDFs) with sanitized text content
8
- # to create rich RSS descriptions that reveal more scraped information.
9
- #
10
- # @example Basic usage
11
- # builder = DescriptionBuilder.new(
12
- # base: "Article content",
13
- # title: "Article Title",
14
- # url: "https://example.com",
15
- # enclosures: [enclosure_object],
16
- # image: "https://example.com/image.jpg"
17
- # )
18
- # description = builder.call
19
- class DescriptionBuilder
20
- # Removes the specified pattern from the beginning of the text
21
- # within a given range if the pattern occurs before the range's end.
22
- #
23
- # @param text [String]
24
- # @param pattern [String]
25
- # @param end_of_range [Integer] Optional, defaults to half the text length
26
- # @return [String]
27
- def self.remove_pattern_from_start(text, pattern, end_of_range: (text.size * 0.5).to_i)
28
- return text unless text.is_a?(String) && pattern.is_a?(String) && !pattern.empty?
29
-
30
- index = text.index(pattern)
31
- return text if index.nil? || index > end_of_range
32
-
33
- "#{text[0, index]}#{text[(index + pattern.size)..]}"
34
- end
35
-
36
- # @param base [String] The base text content for the description
37
- # @param title [String] The article title (used for alt text and title removal)
38
- # @param url [String, Html2rss::Url] The article URL (used for sanitization)
39
- # @param enclosures [Array<Html2rss::RssBuilder::Enclosure>, nil] Media enclosures
40
- # @param image [String, Html2rss::Url, nil] Fallback image URL
41
- def initialize(base:, title:, url:, enclosures:, image:)
42
- @base = base.to_s
43
- @title = title
44
- @url = url
45
- @enclosures = Array(enclosures)
46
- @image = image
47
- end
48
-
49
- # Generates the complete description with media and sanitized text.
50
- #
51
- # @return [String, nil] The complete description or nil if empty
52
- def call
53
- fragments = []
54
- fragments.concat(Array(rendered_media))
55
- fragments << processed_base_description
56
-
57
- result = fragments.compact.join("\n\n").strip
58
- result.empty? ? nil : result
59
- end
60
-
61
- private
62
-
63
- def rendered_media
64
- rendered = render_enclosures
65
- return rendered if rendered.any?
66
- return render_fallback_image if @image
67
-
68
- []
69
- end
70
-
71
- def render_enclosures
72
- @enclosures.filter_map do |enclosure|
73
- MediaRenderer.for(enclosure:, image: @image, title: @title)&.to_html
74
- end
75
- end
76
-
77
- def render_fallback_image
78
- [MediaRenderer.for(enclosure: nil, image: @image, title: @title)&.to_html]
79
- end
80
-
81
- def processed_base_description
82
- text = self.class.remove_pattern_from_start(@base, @title)
83
- Html2rss::Selectors::PostProcessors::SanitizeHtml.get(text, @url)
84
- end
85
- end
86
- end
87
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'cgi'
4
-
5
- module Html2rss
6
- module Rendering
7
- # Renders an HTML <img> tag from a URL and title.
8
- class ImageRenderer
9
- # @param url [String, Html2rss::Url] image URL for the src attribute
10
- # @param title [String, nil] title/alt text for the image
11
- def initialize(url:, title:)
12
- @url = url
13
- @title = title
14
- end
15
-
16
- # @return [String] HTML image snippet for article rendering
17
- def to_html
18
- attributes = [
19
- %(src="#{escaped_url}"),
20
- %(alt="#{escaped_title}"),
21
- %(title="#{escaped_title}"),
22
- 'loading="lazy"',
23
- 'referrerpolicy="no-referrer"',
24
- 'decoding="async"',
25
- 'crossorigin="anonymous"'
26
- ]
27
- "<img #{attributes.join(' ')}>"
28
- end
29
-
30
- private
31
-
32
- def escaped_url
33
- CGI.escapeHTML(@url.to_s)
34
- end
35
-
36
- def escaped_title
37
- CGI.escapeHTML(@title.to_s)
38
- end
39
- end
40
- end
41
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- module Rendering
5
- # Factory: picks the appropriate renderer for a given enclosure or fallback image.
6
- class MediaRenderer
7
- # @param enclosure [Html2rss::RssBuilder::Enclosure, nil]
8
- # @param image [String, Html2rss::Url, nil] Fallback image URL
9
- # @param title [String]
10
- # @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
11
- def self.for(enclosure:, image:, title:)
12
- return ImageRenderer.new(url: image, title:) if enclosure.nil? && image
13
- return nil unless enclosure
14
-
15
- create_renderer_for_type(enclosure.type, url: enclosure.url, title:)
16
- end
17
-
18
- # @private
19
- # @param type [String, nil] enclosure MIME type
20
- # @param url [String, Html2rss::Url] enclosure URL
21
- # @param title [String, nil] title used by image renderer
22
- # @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
23
- def self.create_renderer_for_type(type, url:, title:)
24
- case type
25
- when %r{^image/}
26
- ImageRenderer.new(url:, title:)
27
- when %r{^video/}
28
- VideoRenderer.new(url:, type:)
29
- when %r{^audio/}
30
- AudioRenderer.new(url:, type:)
31
- when 'application/pdf'
32
- PdfRenderer.new(url:)
33
- end
34
- end
35
- end
36
- end
37
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'cgi'
4
-
5
- module Html2rss
6
- module Rendering
7
- # Renders an HTML <iframe> for PDF documents.
8
- class PdfRenderer
9
- # @param url [String, Html2rss::Url] PDF URL rendered in the iframe
10
- def initialize(url:)
11
- @url = url
12
- end
13
-
14
- # @return [String] HTML iframe snippet for PDF rendering
15
- def to_html
16
- attributes = [
17
- %(src="#{escaped_url}"),
18
- 'width="100%"',
19
- 'height="75vh"',
20
- 'sandbox=""',
21
- 'referrerpolicy="no-referrer"',
22
- 'loading="lazy"'
23
- ]
24
- "<iframe #{attributes.join(' ')}></iframe>"
25
- end
26
-
27
- private
28
-
29
- def escaped_url
30
- CGI.escapeHTML(@url.to_s)
31
- end
32
- end
33
- end
34
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'cgi'
4
-
5
- module Html2rss
6
- module Rendering
7
- # Renders an HTML <video> tag from a URL and type.
8
- class VideoRenderer
9
- # @param url [String, Html2rss::Url] media URL for the video source
10
- # @param type [String] MIME type for the video source
11
- def initialize(url:, type:)
12
- @url = url
13
- @type = type
14
- end
15
-
16
- # @return [String] HTML video snippet for article rendering
17
- def to_html
18
- [
19
- '<video controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous" playsinline>',
20
- %(<source src="#{escaped_url}" type="#{escaped_type}">),
21
- '</video>'
22
- ].join
23
- end
24
-
25
- private
26
-
27
- def escaped_url
28
- CGI.escapeHTML(@url.to_s)
29
- end
30
-
31
- def escaped_type
32
- CGI.escapeHTML(@type.to_s)
33
- end
34
- end
35
- end
36
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- # Namespace for HTML rendering logic, used to generate rich content such as
5
- # images, audio, video, or embedded documents for feed descriptions.
6
- #
7
- # @see Html2rss::Rendering::DescriptionBuilder
8
- #
9
- # @example
10
- # Html2rss::Rendering::ImageRenderer.new(
11
- # url: "https://example.com/image.jpg",
12
- # title: "Example"
13
- # ).to_html
14
- #
15
- # @example
16
- # Html2rss::Rendering::MediaRenderer.for(
17
- # enclosure: nil,
18
- # image: "https://example.com/image.jpg",
19
- # title: "Example"
20
- # )
21
- module Rendering
22
- end
23
- end
@@ -1,133 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- ##
5
- # Tracks runtime request controls together with whether each value was explicitly set.
6
- class RequestControls
7
- # Request-control keys accepted at the top level of feed config.
8
- TOP_LEVEL_KEYS = %i[strategy].freeze
9
- # Request-control keys accepted under the nested `request` config.
10
- REQUEST_KEYS = %i[max_redirects max_requests total_timeout_seconds].freeze
11
-
12
- ##
13
- # @param config [Hash{Symbol => Object}] raw config input
14
- # @return [RequestControls] request controls extracted from the config hash
15
- def self.from_config(config)
16
- HashUtil.assert_symbol_keys!(config, context: 'config', deep: false)
17
- HashUtil.assert_symbol_keys!(config[:request], context: 'config[:request]') if config[:request].is_a?(Hash)
18
-
19
- new(
20
- strategy: config[:strategy],
21
- max_redirects: request_value_for(config, :max_redirects),
22
- max_requests: request_value_for(config, :max_requests),
23
- total_timeout_seconds: request_value_for(config, :total_timeout_seconds),
24
- explicit_keys: explicit_keys_for(config)
25
- )
26
- end
27
-
28
- def self.explicit_keys_for(config)
29
- TOP_LEVEL_KEYS.filter { config.key?(_1) } +
30
- REQUEST_KEYS.filter { request_key?(config, _1) }
31
- end
32
-
33
- def self.request_value_for(config, key)
34
- request_config = config[:request]
35
- return nil unless request_config.is_a?(Hash)
36
-
37
- request_config[key]
38
- end
39
-
40
- def self.request_key?(config, key)
41
- request_config = config[:request]
42
- request_config.is_a?(Hash) && request_config.key?(key)
43
- end
44
-
45
- private_class_method :explicit_keys_for, :request_value_for, :request_key?
46
-
47
- ##
48
- # @param strategy [Symbol, nil] effective request strategy
49
- # @param max_redirects [Integer, nil] effective redirect limit
50
- # @param max_requests [Integer, nil] effective request budget
51
- # @param total_timeout_seconds [Integer, nil] effective request timeout
52
- # @param explicit_keys [Array<Symbol>] controls explicitly supplied by the caller
53
- def initialize(strategy: nil, max_redirects: nil, max_requests: nil, total_timeout_seconds: nil, explicit_keys: [])
54
- @strategy = strategy
55
- @max_redirects = max_redirects
56
- @max_requests = max_requests
57
- @total_timeout_seconds = total_timeout_seconds
58
- @explicit_keys = explicit_keys.map(&:to_sym).uniq.freeze
59
- freeze
60
- end
61
-
62
- ##
63
- # @return [Symbol, nil] effective request strategy
64
- attr_reader :strategy
65
-
66
- ##
67
- # @return [Integer, nil] effective redirect limit
68
- attr_reader :max_redirects
69
-
70
- ##
71
- # @return [Integer, nil] effective request budget
72
- attr_reader :max_requests
73
-
74
- ##
75
- # @return [Integer, nil] effective request timeout
76
- attr_reader :total_timeout_seconds
77
-
78
- ##
79
- # @param name [Symbol, String] request control name
80
- # @return [Boolean] whether the control was explicitly supplied
81
- def explicit?(name)
82
- explicit_keys.include?(name.to_sym)
83
- end
84
-
85
- ##
86
- # @param strategy [Symbol, nil] validated request strategy
87
- # @param max_redirects [Integer, nil] validated redirect limit
88
- # @param max_requests [Integer, nil] validated request budget
89
- # @param total_timeout_seconds [Integer, nil] validated request timeout
90
- # @return [RequestControls] controls updated with validated effective values
91
- def with_effective_values(strategy:, max_redirects:, max_requests:, total_timeout_seconds:)
92
- self.class.new(
93
- strategy:,
94
- max_redirects:,
95
- max_requests:,
96
- total_timeout_seconds:,
97
- explicit_keys:
98
- )
99
- end
100
-
101
- ##
102
- # Applies only explicitly set controls to the provided config hash.
103
- #
104
- # @param config [Hash{Symbol => Object}] mutable config hash
105
- # @return [Hash{Symbol => Object}] the same hash with explicit controls written
106
- def apply_to(config)
107
- config[:strategy] = strategy if explicit?(:strategy)
108
- apply_request_value(config, :max_redirects, max_redirects)
109
- apply_request_value(config, :max_requests, max_requests)
110
- apply_request_value(config, :total_timeout_seconds, total_timeout_seconds)
111
- config
112
- end
113
-
114
- private
115
-
116
- attr_reader :explicit_keys
117
-
118
- def apply_request_value(config, key, value)
119
- return unless explicit?(key)
120
-
121
- ensure_request_config!(config)
122
- config[:request][key] = value
123
- end
124
-
125
- def ensure_request_config!(config)
126
- request_config = config[:request]
127
- return config[:request] = {} if request_config.nil?
128
- return if request_config.is_a?(Hash)
129
-
130
- raise ArgumentError, 'request config must be a hash'
131
- end
132
- end
133
- end
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestSession
5
- ##
6
- # Traverses a rel=next pagination chain for selector-driven extraction.
7
- class RelNextPager
8
- include Enumerable
9
-
10
- ##
11
- # @param session [RequestSession] request session used to execute follow-ups
12
- # @param initial_response [RequestService::Response] first page response
13
- # @param max_pages [Integer] configured page budget, including the initial page
14
- # @param logger [Logger] logger used for pagination stop reasons
15
- def initialize(session:, initial_response:, max_pages:, logger: Html2rss::Log)
16
- @session = session
17
- @initial_response = initial_response
18
- @max_pages = max_pages
19
- @logger = logger
20
- end
21
-
22
- ##
23
- # Iterates over all paginated responses, beginning with the initial response.
24
- #
25
- # @yield [RequestService::Response] each page response
26
- # @return [Enumerator] enumerator when no block is given
27
- def each
28
- return enum_for(:each) unless block_given?
29
-
30
- yield initial_response
31
-
32
- current_response = initial_response
33
- session.effective_page_budget(max_pages).pred.times do
34
- next_url = next_page_url(current_response)
35
- break unless follow_up_allowed?(next_url)
36
-
37
- current_response = fetch_follow_up_response_or_stop(next_url, current_response.url)
38
- break unless current_response
39
-
40
- yield current_response
41
- end
42
- end
43
-
44
- private
45
-
46
- attr_reader :session, :initial_response, :max_pages, :logger
47
-
48
- def next_page_url(page_response)
49
- href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
50
- return nil if href.nil? || href.empty?
51
-
52
- Html2rss::Url.from_relative(href, page_response.url)
53
- end
54
-
55
- def follow_up_allowed?(next_url)
56
- next_url && !session.visited?(next_url)
57
- end
58
-
59
- def fetch_follow_up_response_or_stop(next_url, origin_url)
60
- session.follow_up(url: next_url, relation: :pagination, origin_url:)
61
- rescue RequestService::RequestBudgetExceeded => error
62
- logger.warn(
63
- "#{self.class}: pagination stopped at #{next_url} - #{error.message}. " \
64
- "Retry with --max-requests #{session.max_requests + 1} or increase request.max_requests in the config."
65
- )
66
- nil
67
- end
68
- end
69
- end
70
- end