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,71 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestSession
5
- ##
6
- # Carries the runtime request inputs needed to build a RequestSession.
7
- class RuntimeInput
8
- ##
9
- # @param config [Html2rss::Config] validated feed config
10
- # @return [RuntimeInput] runtime request inputs derived from the config
11
- def self.from_config(config)
12
- new(
13
- url: config.url,
14
- headers: config.headers,
15
- request: config.request,
16
- strategy: config.strategy,
17
- request_policy: RuntimePolicy.from_config(config)
18
- )
19
- end
20
-
21
- ##
22
- # @param url [String, Html2rss::Url] initial request URL
23
- # @param headers [Hash] normalized request headers
24
- # @param request [Hash] validated request options for strategies
25
- # @param strategy [Symbol] request strategy to use for the session
26
- # @param request_policy [RequestService::Policy] request policy for the session
27
- def initialize(url:, headers:, request:, strategy:, request_policy:)
28
- @url = Html2rss::Url.from_absolute(url)
29
- @headers = normalize_headers(headers).freeze
30
- @request = normalize_request(request).freeze
31
- @strategy = strategy
32
- @request_policy = request_policy
33
- freeze
34
- end
35
-
36
- ##
37
- # @return [Html2rss::Url] initial request URL
38
- attr_reader :url
39
-
40
- ##
41
- # @return [Hash] normalized request headers
42
- attr_reader :headers
43
-
44
- ##
45
- # @return [Hash] validated request options for strategies
46
- attr_reader :request
47
-
48
- ##
49
- # @return [Symbol] request strategy to use for the session
50
- attr_reader :strategy
51
-
52
- ##
53
- # @return [RequestService::Policy] policy derived from the runtime request inputs
54
- attr_reader :request_policy
55
-
56
- private
57
-
58
- def normalize_headers(headers)
59
- headers.to_h do |key, value|
60
- [key.to_s, value]
61
- end
62
- end
63
-
64
- def normalize_request(request)
65
- normalized = HashUtil.deep_symbolize_keys(request, context: 'request')
66
- HashUtil.assert_symbol_keys!(normalized, context: 'request')
67
- normalized
68
- end
69
- end
70
- end
71
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RequestSession
5
- ##
6
- # Builds the runtime request policy for a feed run.
7
- class RuntimePolicy
8
- ##
9
- # @param config [Html2rss::Config] validated feed config
10
- # @return [Html2rss::RequestService::Policy] request policy derived from runtime config
11
- def self.from_config(config)
12
- RequestService::Policy.new(
13
- max_requests: effective_max_requests_for(config),
14
- max_redirects: config.max_redirects,
15
- total_timeout_seconds: config.total_timeout_seconds || RequestService::Policy::DEFAULTS[:total_timeout_seconds]
16
- )
17
- end
18
-
19
- class << self
20
- private
21
-
22
- def effective_max_requests_for(config)
23
- return config.max_requests if config.explicit_max_requests?
24
-
25
- [baseline_request_budget_for(config), config.max_requests].max
26
- end
27
-
28
- # Reserve enough budget for the initial request plus predictable follow-ups
29
- # that the top-level pipeline may trigger during a normal feed build.
30
- def baseline_request_budget_for(config)
31
- 1 + pagination_follow_up_budget_for(config) +
32
- known_auto_source_follow_up_budget_for(config) +
33
- auto_strategy_fallback_budget_for(config) +
34
- browserless_preload_budget_for(config)
35
- end
36
-
37
- def auto_strategy_fallback_budget_for(config)
38
- return 0 unless config.strategy == :auto
39
-
40
- [FeedPipeline::AutoFallback::CHAIN.size - 1, 0].max
41
- end
42
-
43
- def pagination_follow_up_budget_for(config)
44
- [config.selectors&.dig(:items, :pagination, :max_pages).to_i - 1, 0].max
45
- end
46
-
47
- def known_auto_source_follow_up_budget_for(config)
48
- config.auto_source&.dig(:scraper, :wordpress_api, :enabled) ? 1 : 0
49
- end
50
-
51
- def browserless_preload_budget_for(config)
52
- preload = config.request.dig(:browserless, :preload)
53
- return 0 unless preload
54
-
55
- top_level_preload_wait_budget(preload) +
56
- click_selector_preload_budget(preload) +
57
- scroll_preload_budget(preload)
58
- end
59
-
60
- def top_level_preload_wait_budget(preload)
61
- preload[:wait_after_ms] ? 2 : 0
62
- end
63
-
64
- def click_selector_preload_budget(preload)
65
- preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
66
- end
67
-
68
- def scroll_preload_budget(preload)
69
- scroll = preload[:scroll_down]
70
- return 0 unless scroll
71
-
72
- preload_action_budget(scroll, :iterations)
73
- end
74
-
75
- def preload_action_budget(config, count_key)
76
- action_count = config.fetch(count_key, 1)
77
- wait_budget = config[:wait_after_ms] ? action_count : 0
78
-
79
- action_count + wait_budget
80
- end
81
- end
82
- end
83
- end
84
- end
@@ -1,208 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'zlib'
4
- require 'sanitize'
5
- require 'nokogiri'
6
-
7
- module Html2rss
8
- class RssBuilder
9
- ##
10
- # Article is a simple data object representing an article extracted from a page.
11
- # It is enumerable and responds to all keys specified in PROVIDED_KEYS.
12
- # rubocop:disable Metrics/ClassLength
13
- class Article
14
- include Enumerable
15
- include Comparable
16
-
17
- # Allowed article attributes accepted by the value object constructor.
18
- PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
19
- # Separator used to build deterministic deduplication fingerprints.
20
- DEDUP_FINGERPRINT_SEPARATOR = '#!/'
21
- # Sentinel object used to pre-initialize instance variables in the constructor.
22
- # This ensures all Article instances share the exact same object shape (Ruby 3.3+ optimization),
23
- # preventing performance warnings and slower instance variable access due to shape transitions
24
- # when attributes are lazily/conditionally accessed in different sequences.
25
- NOT_SET = Object.new.freeze
26
-
27
- # @param options [Hash{Symbol => String}]
28
- # @option options [String] :id stable article identifier
29
- # @option options [String] :title article title
30
- # @option options [String] :description article description/content
31
- # @option options [String, Html2rss::Url] :url canonical article URL
32
- # @option options [String, Html2rss::Url] :image image URL for fallback enclosure rendering
33
- # @option options [String] :author author name
34
- # @option options [String] :guid explicit GUID override
35
- # @option options [String, Time, DateTime] :published_at publication timestamp
36
- # @option options [Array<Hash{Symbol => Object}>] :enclosures enclosure attribute hashes
37
- # @option options [Array<String>] :categories category labels
38
- # @option options [Class] :scraper scraper class that produced the article
39
- def initialize(**options)
40
- @to_h = options.each_with_object({}) { |(k, v), h| h[k] = v.freeze if v }.freeze
41
-
42
- @description = @url = @image = @guid = @enclosures = @enclosure = @categories = @published_at = NOT_SET
43
-
44
- return unless (unknown_keys = options.keys - PROVIDED_KEYS).any?
45
-
46
- Log.warn "Article: unknown keys found: #{unknown_keys.join(', ')}"
47
- end
48
-
49
- # Checks if the article is valid based on the presence of URL, ID, and either title or description.
50
- # @return [Boolean] True if the article is valid, otherwise false.
51
- def valid?
52
- !url.to_s.empty? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
53
- end
54
-
55
- # @yield [key, value]
56
- # @return [Enumerator] if no block is given
57
- def each
58
- return enum_for(:each) unless block_given?
59
-
60
- PROVIDED_KEYS.each { |key| yield(key, public_send(key)) }
61
- end
62
-
63
- # @return [String, nil] stable article identifier
64
- def id = blank_string_to_nil(@to_h[:id])
65
-
66
- # @return [String, nil] article title
67
- def title = blank_string_to_nil(@to_h[:title])
68
-
69
- # @return [String] rendered article description
70
- def description
71
- return @description unless @description == NOT_SET
72
-
73
- @description = Rendering::DescriptionBuilder.new(
74
- base: @to_h[:description],
75
- title:,
76
- url:,
77
- enclosures:,
78
- image:
79
- ).call
80
- end
81
-
82
- # @return [Url, nil]
83
- def url
84
- return @url unless @url == NOT_SET
85
-
86
- @url = Url.sanitize(@to_h[:url])
87
- end
88
-
89
- # @return [Url, nil]
90
- def image
91
- return @image unless @image == NOT_SET
92
-
93
- @image = Url.sanitize(@to_h[:image])
94
- end
95
-
96
- # @return [String, nil]
97
- def author = blank_string_to_nil(@to_h[:author])
98
-
99
- # Generates a unique identifier based on the URL and ID using CRC32.
100
- # @return [String]
101
- def guid
102
- return @guid unless @guid == NOT_SET
103
-
104
- @guid = Zlib.crc32(fetch_guid).to_s(36).encode('utf-8')
105
- end
106
-
107
- ##
108
- # Returns a deterministic fingerprint used to detect duplicate articles.
109
- #
110
- # @return [String, Integer]
111
- def deduplication_fingerprint
112
- dedup_from_url || dedup_from_id || dedup_from_guid || hash
113
- end
114
-
115
- # @return [Array<Html2rss::RssBuilder::Enclosure>] normalized enclosure objects
116
- def enclosures
117
- return @enclosures unless @enclosures == NOT_SET
118
-
119
- @enclosures = Array(@to_h[:enclosures])
120
- .map { |enclosure| Html2rss::RssBuilder::Enclosure.new(**enclosure) }
121
- end
122
-
123
- # @return [Html2rss::RssBuilder::Enclosure, nil]
124
- def enclosure
125
- return @enclosure unless @enclosure == NOT_SET
126
-
127
- @enclosure = case (object = @to_h[:enclosures]&.first)
128
- when Hash
129
- Html2rss::RssBuilder::Enclosure.new(**object)
130
- when nil
131
- Html2rss::RssBuilder::Enclosure.new(url: image) if image
132
- else
133
- Log.warn "Article: unknown enclosure type: #{object.class}"
134
- nil
135
- end
136
- end
137
-
138
- # @return [Array<String>] normalized, unique category names
139
- def categories
140
- return @categories unless @categories == NOT_SET
141
-
142
- @categories = @to_h[:categories].dup.to_a.tap do |categories|
143
- categories.map! { |category| category.to_s.strip }
144
- categories.reject!(&:empty?)
145
- categories.uniq!
146
- end
147
- end
148
-
149
- # Parses and returns the published_at time.
150
- # @return [DateTime, nil]
151
- def published_at
152
- return @published_at unless @published_at == NOT_SET
153
-
154
- string = @to_h[:published_at].to_s.strip
155
- @published_at = string.empty? ? nil : DateTime.parse(string)
156
- rescue ArgumentError
157
- @published_at = nil
158
- end
159
-
160
- # @return [Class, nil] scraper class that produced this article
161
- def scraper
162
- @to_h[:scraper]
163
- end
164
-
165
- # @param other [Object] value compared against this article
166
- # @return [Integer, nil] comparison result for compatible Article values
167
- def <=>(other)
168
- return nil unless other.is_a?(Article)
169
-
170
- 0 if other.all? { |key, value| value == public_send(key) ? public_send(key) <=> value : false }
171
- end
172
-
173
- private
174
-
175
- def dedup_from_url
176
- return unless (value = url)
177
-
178
- [value.to_s, id].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
179
- end
180
-
181
- def dedup_from_id
182
- return if id.to_s.empty?
183
-
184
- id
185
- end
186
-
187
- def dedup_from_guid
188
- value = guid
189
- return if value.to_s.empty?
190
-
191
- [value, title, description].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
192
- end
193
-
194
- def fetch_guid
195
- guid = @to_h[:guid].map { |s| s.to_s.strip }.reject(&:empty?).join if @to_h[:guid].is_a?(Array)
196
-
197
- guid || [url, id].join('#!/')
198
- end
199
-
200
- def blank_string_to_nil(value)
201
- return if value.is_a?(String) && value.strip.empty?
202
-
203
- value
204
- end
205
- end
206
- # rubocop:enable Metrics/ClassLength
207
- end
208
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RssBuilder
5
- ##
6
- # Extracts channel information from
7
- # 1. the HTML document's <head>.
8
- # 2. the HTTP response
9
- class Channel
10
- # Fallback RSS ttl (in minutes) when no cache directives are present.
11
- DEFAULT_TTL_IN_MINUTES = 360
12
- # Description template used when no explicit or discovered description exists.
13
- DEFAULT_DESCRIPTION_TEMPLATE = 'Latest items from %<url>s'
14
-
15
- ##
16
- # @param response [Html2rss::RequestService::Response]
17
- # @param overrides [Hash{Symbol => String}] optional overrides for channel attributes
18
- def initialize(response, overrides: {})
19
- @response = response
20
- @overrides = overrides
21
- end
22
-
23
- # @return [String] channel title derived from overrides, document title, or URL
24
- def title
25
- @title ||= fetch_title
26
- end
27
-
28
- # @return [Html2rss::Url] canonical channel URL
29
- def url = @url ||= Html2rss::Url.from_absolute(@response.url)
30
-
31
- # @return [String] channel description text
32
- def description
33
- return overrides[:description] unless overrides[:description].to_s.empty?
34
-
35
- description = parsed_body.at_css('meta[name="description"]')&.[]('content') if html_response?
36
-
37
- return format(DEFAULT_DESCRIPTION_TEMPLATE, url:) if description.to_s.empty?
38
-
39
- description
40
- end
41
-
42
- # @return [Integer] cache time-to-live in minutes
43
- def ttl
44
- calculated = if overrides[:ttl]
45
- overrides[:ttl].to_i
46
- elsif (max_age = headers['cache-control']&.match(/max-age=(\d+)/)&.[](1))
47
- max_age.to_i.fdiv(60).ceil
48
- else
49
- DEFAULT_TTL_IN_MINUTES
50
- end
51
-
52
- min_ttl = Html2rss.configuration.min_ttl
53
- min_ttl ? [calculated, min_ttl].max : calculated
54
- end
55
-
56
- # @return [String, nil] ISO-like language code when available
57
- def language
58
- return overrides[:language] if overrides[:language]
59
-
60
- if (language_code = headers['content-language']&.match(/^([a-z]{2})/))
61
- return language_code[0]
62
- end
63
-
64
- return unless html_response?
65
-
66
- parsed_body['lang'] || parsed_body.at_css('[lang]')&.[]('lang')
67
- end
68
-
69
- # @return [String, nil] channel author metadata
70
- def author
71
- return overrides[:author] if overrides[:author]
72
-
73
- return unless html_response?
74
-
75
- parsed_body.at_css('meta[name="author"]')&.[]('content')
76
- end
77
-
78
- # @return [String, Time] source last-modified timestamp or current time fallback
79
- def last_build_date = headers['last-modified'] || Time.now
80
-
81
- # @return [Html2rss::Url, nil] channel image URL
82
- def image
83
- return overrides[:image] if overrides[:image]
84
-
85
- return unless html_response?
86
-
87
- if (image_url = parsed_body.at_css('meta[property="og:image"]')&.[]('content'))
88
- Url.sanitize(image_url)
89
- end
90
- end
91
-
92
- private
93
-
94
- attr_reader :overrides
95
-
96
- def parsed_body = @parsed_body ||= @response.parsed_body
97
- def headers = @headers ||= @response.headers
98
- def html_response? = @html_response ||= @response.html_response?
99
-
100
- def fetch_title
101
- override_title = overrides[:title]
102
- return override_title if override_title
103
- return parsed_title if parsed_title
104
-
105
- url.channel_titleized
106
- end
107
-
108
- def parsed_title
109
- return unless html_response?
110
-
111
- title = parsed_body.at_css('head > title')&.text.to_s
112
- return if title.empty?
113
-
114
- title.gsub(/\s+/, ' ').strip
115
- end
116
- end
117
- end
118
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class RssBuilder
5
- ##
6
- # Represents a stylesheet.
7
- class Stylesheet
8
- class << self
9
- ##
10
- # Adds the stylesheet XML tags to the RSS.
11
- #
12
- # @param maker [RSS::Maker::RSS20] RSS maker object.
13
- # @param stylesheets [Array<Html2rss::RssBuilder::Stylesheet>] Array of stylesheet configurations.
14
- # @return [nil]
15
- def add(maker, stylesheets)
16
- stylesheets.each do |stylesheet|
17
- add_stylesheet(maker, stylesheet)
18
- end
19
- end
20
-
21
- private
22
-
23
- ##
24
- # Adds a single Stylesheet to the RSS.
25
- #
26
- # @param maker [RSS::Maker::RSS20] RSS maker object.
27
- # @param stylesheet [Html2rss::RssBuilder::Stylesheet] Stylesheet configuration.
28
- # @return [nil]
29
- def add_stylesheet(maker, stylesheet)
30
- maker.xml_stylesheets.new_xml_stylesheet do |xss|
31
- xss.href = stylesheet.href
32
- xss.type = stylesheet.type
33
- xss.media = stylesheet.media
34
- end
35
- end
36
- end
37
-
38
- # Allowed stylesheet MIME types for RSS processing instructions.
39
- TYPES = ['text/css', 'text/xsl'].to_set.freeze
40
-
41
- # @param href [String] stylesheet URL
42
- # @param type [String] MIME type (`text/css` or `text/xsl`)
43
- # @param media [String] media query hint for the stylesheet
44
- def initialize(href:, type:, media: 'all')
45
- raise ArgumentError, 'stylesheet.href must be a String' unless href.is_a?(String)
46
- raise ArgumentError, 'stylesheet.type invalid' unless TYPES.include?(type)
47
- raise ArgumentError, 'stylesheet.media must be a String' unless media.is_a?(String)
48
-
49
- @href = href
50
- @type = type
51
- @media = media
52
- end
53
- attr_reader :href, :type, :media
54
-
55
- # @return [String] the XML representation of the stylesheet
56
- def to_xml
57
- <<~XML
58
- <?xml-stylesheet href="#{href}" type="#{type}" media="#{media}"?>
59
- XML
60
- end
61
- end
62
- end
63
- end
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rss'
4
-
5
- module Html2rss
6
- ##
7
- # Builds an RSS Feed by providing channel, articles and stylesheets.
8
- class RssBuilder
9
- class << self
10
- # @param article [Html2rss::RssBuilder::Article] source article
11
- # @param item_maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
12
- # @return [void]
13
- def add_item(article, item_maker)
14
- add_item_string_values(article, item_maker)
15
- add_item_categories(article, item_maker)
16
- Enclosure.add(article.enclosure, item_maker)
17
- add_item_guid(article, item_maker)
18
- end
19
-
20
- private
21
-
22
- def add_item_string_values(article, item_maker)
23
- %i[title description author].each do |attr|
24
- next unless (value = article.send(attr))
25
- next if value.empty?
26
-
27
- item_maker.send(:"#{attr}=", value)
28
- end
29
-
30
- item_maker.link = article.url.to_s if article.url
31
- item_maker.pubDate = article.published_at&.rfc2822
32
- end
33
-
34
- def add_item_categories(article, item_maker)
35
- article.categories.each { |category| item_maker.categories.new_category.content = category }
36
- end
37
-
38
- def add_item_guid(article, item_maker)
39
- item_maker.guid.tap do |guid|
40
- guid.content = article.guid
41
- guid.isPermaLink = false
42
- end
43
- end
44
- end
45
-
46
- ##
47
- # @param channel [Html2rss::RssBuilder::Channel] The channel information for the RSS feed.
48
- # @param articles [Array<Html2rss::RssBuilder::Article>] The list of articles to include in the RSS feed.
49
- # @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
50
- def initialize(channel:, articles:, stylesheets: [])
51
- @channel = channel
52
- @articles = articles
53
- @stylesheets = stylesheets
54
- end
55
-
56
- # @return [RSS::Rss] RSS 2.0 document instance
57
- def call
58
- RSS::Maker.make('2.0') do |maker|
59
- Stylesheet.add(maker, stylesheets)
60
-
61
- make_channel(maker.channel)
62
- make_items(maker)
63
- end
64
- end
65
-
66
- private
67
-
68
- attr_reader :channel, :articles
69
-
70
- def stylesheets
71
- @stylesheets.map { |style| Stylesheet.new(**style) }
72
- end
73
-
74
- def make_channel(maker)
75
- %i[language title description ttl].each do |key|
76
- maker.public_send(:"#{key}=", channel.public_send(key))
77
- end
78
-
79
- maker.link = channel.url.to_s
80
- maker.generator = generator
81
- maker.updated = channel.last_build_date
82
- end
83
-
84
- def make_items(maker)
85
- articles.each do |article|
86
- maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
87
- end
88
- end
89
-
90
- def generator
91
- scraper_namespace_regex = /(?<namespace>Html2rss|Scraper)::/
92
-
93
- scraper_counts = articles.flat_map(&:scraper).tally.map do |klass, count|
94
- scraper_name = klass.to_s.gsub(scraper_namespace_regex, '')
95
- "#{scraper_name} (#{count})"
96
- end
97
-
98
- "html2rss V. #{Html2rss::VERSION} (scrapers: #{scraper_counts.join(', ')})"
99
- end
100
- end
101
- end