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.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -18,7 +18,9 @@ module Html2rss
|
|
|
18
18
|
include Enumerable
|
|
19
19
|
|
|
20
20
|
# A context instance passed to item extractors and post-processors.
|
|
21
|
-
|
|
21
|
+
# When built via {ItemScope#context_for}, +item_scope+ carries the per-item
|
|
22
|
+
# extraction base_url for nested selects (e.g. Template).
|
|
23
|
+
Context = Struct.new('Context', :options, :item, :config, :scraper, :item_scope, keyword_init: true)
|
|
22
24
|
|
|
23
25
|
# Default selectors options merged into user configuration.
|
|
24
26
|
DEFAULT_CONFIG = { items: { enhance: true } }.freeze
|
|
@@ -29,9 +31,11 @@ module Html2rss
|
|
|
29
31
|
ITEM_TAGS = %i[title url description author comments published_at guid enclosure categories].freeze
|
|
30
32
|
# Item attributes that require dedicated extraction logic.
|
|
31
33
|
SPECIAL_ATTRIBUTES = Set[:guid, :enclosure, :categories].freeze
|
|
32
|
-
|
|
33
|
-
#
|
|
34
|
-
|
|
34
|
+
# Config selector keys that map onto a different {Article} attribute.
|
|
35
|
+
# +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
|
|
36
|
+
SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
|
|
37
|
+
# Selector keys that may be copied onto an Article (PROVIDED_KEYS + mapped aliases).
|
|
38
|
+
SELECTABLE_SELECTOR_KEYS = (Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys).to_set.freeze
|
|
35
39
|
|
|
36
40
|
##
|
|
37
41
|
# Initializes a new Selectors instance.
|
|
@@ -46,14 +50,14 @@ module Html2rss
|
|
|
46
50
|
@time_zone = time_zone
|
|
47
51
|
|
|
48
52
|
prepare_selectors!
|
|
49
|
-
@rss_item_attributes = @selectors.keys
|
|
53
|
+
@rss_item_attributes = @selectors.keys.select { |key| SELECTABLE_SELECTOR_KEYS.include?(key) }
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
##
|
|
53
57
|
# Returns articles extracted from the response.
|
|
54
58
|
# Reverses order if config specifies reverse ordering.
|
|
55
59
|
#
|
|
56
|
-
# @return [Array<Html2rss::
|
|
60
|
+
# @return [Array<Html2rss::Article>]
|
|
57
61
|
def articles
|
|
58
62
|
@articles ||= @selectors.dig(ITEMS_SELECTOR_KEY, :order) == 'reverse' ? to_a.tap(&:reverse!) : to_a
|
|
59
63
|
end
|
|
@@ -61,7 +65,7 @@ module Html2rss
|
|
|
61
65
|
##
|
|
62
66
|
# Iterates over each scraped article.
|
|
63
67
|
#
|
|
64
|
-
# @yield [article] Gives each article as an Html2rss::
|
|
68
|
+
# @yield [article] Gives each article as an Html2rss::Article.
|
|
65
69
|
# @return [Enumerator] An enumerator if no block is given.
|
|
66
70
|
def each(&)
|
|
67
71
|
return enum_for(:each) unless block_given?
|
|
@@ -73,7 +77,7 @@ module Html2rss
|
|
|
73
77
|
|
|
74
78
|
enhance_article_hash(article_hash, item, response.url) if enhance
|
|
75
79
|
|
|
76
|
-
yield Html2rss::
|
|
80
|
+
yield Html2rss::Article.new(**article_hash, scraper: self.class)
|
|
77
81
|
end
|
|
78
82
|
end
|
|
79
83
|
|
|
@@ -92,7 +96,14 @@ module Html2rss
|
|
|
92
96
|
# @param page_response [RequestService::Response] response used for selector extraction context
|
|
93
97
|
# @return [Hash] Hash of attributes for the article.
|
|
94
98
|
def extract_article(item, page_response = response)
|
|
95
|
-
|
|
99
|
+
scope = item_scope_for(item, page_response.url)
|
|
100
|
+
@rss_item_attributes.each_with_object({}) do |selector_key, hash|
|
|
101
|
+
value = scope.select(selector_key)
|
|
102
|
+
next if value.nil?
|
|
103
|
+
|
|
104
|
+
article_key = SELECTOR_TO_ARTICLE_KEY.fetch(selector_key, selector_key)
|
|
105
|
+
hash[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
|
|
106
|
+
end
|
|
96
107
|
end
|
|
97
108
|
|
|
98
109
|
##
|
|
@@ -105,13 +116,13 @@ module Html2rss
|
|
|
105
116
|
# @return [Hash] The enhanced article hash.
|
|
106
117
|
# rubocop:disable Metrics/MethodLength
|
|
107
118
|
def enhance_article_hash(article_hash, article_tag, base_url = @url)
|
|
108
|
-
selected_anchor =
|
|
109
|
-
extracted =
|
|
119
|
+
selected_anchor = Html2rss::Html::Navigator.main_anchor_for(article_tag)
|
|
120
|
+
extracted = Html2rss::Html::ArticleExtractor.call(
|
|
110
121
|
article_tag,
|
|
111
122
|
base_url:,
|
|
112
123
|
selected_anchor:,
|
|
113
124
|
fallback_anchorless: true
|
|
114
|
-
)
|
|
125
|
+
)
|
|
115
126
|
return article_hash unless extracted
|
|
116
127
|
|
|
117
128
|
extracted.each_with_object(article_hash) do |(key, value), hash|
|
|
@@ -131,6 +142,18 @@ module Html2rss
|
|
|
131
142
|
# @return [Object, Array<Object>] The selected value(s).
|
|
132
143
|
# @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
|
|
133
144
|
def select(name, item, base_url: @url)
|
|
145
|
+
select_in_scope(name, item_scope_for(item, base_url))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
##
|
|
149
|
+
# Selects the value for a given attribute within an existing {ItemScope}.
|
|
150
|
+
# Used by {ItemScope#select} so nested selects reuse one scope per extraction pass.
|
|
151
|
+
#
|
|
152
|
+
# @param name [Symbol, String] Name of the attribute.
|
|
153
|
+
# @param scope [ItemScope] Per-item extraction scope.
|
|
154
|
+
# @return [Object, Array<Object>] The selected value(s).
|
|
155
|
+
# @raise [InvalidSelectorName] If the attribute name is invalid or not defined.
|
|
156
|
+
def select_in_scope(name, scope)
|
|
134
157
|
name = name.to_sym
|
|
135
158
|
|
|
136
159
|
raise InvalidSelectorName, "Attribute selector '#{name}' is reserved for items." if name == ITEMS_SELECTOR_KEY
|
|
@@ -138,9 +161,9 @@ module Html2rss
|
|
|
138
161
|
selector_key, config = selector_config_for(name)
|
|
139
162
|
|
|
140
163
|
if SPECIAL_ATTRIBUTES.member?(selector_key)
|
|
141
|
-
select_special(selector_key,
|
|
164
|
+
select_special(selector_key, scope:, config:)
|
|
142
165
|
else
|
|
143
|
-
select_regular(selector_key,
|
|
166
|
+
select_regular(selector_key, scope:, config:)
|
|
144
167
|
end
|
|
145
168
|
end
|
|
146
169
|
|
|
@@ -148,10 +171,18 @@ module Html2rss
|
|
|
148
171
|
|
|
149
172
|
attr_reader :response
|
|
150
173
|
|
|
174
|
+
def item_scope_for(item, base_url)
|
|
175
|
+
ItemScope.new(
|
|
176
|
+
item:,
|
|
177
|
+
base_url:,
|
|
178
|
+
scraper: self,
|
|
179
|
+
channel: channel_context(base_url)
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
151
183
|
def prepare_selectors!
|
|
152
184
|
validate_url_and_link_exclusivity!
|
|
153
185
|
fix_url_and_link!
|
|
154
|
-
handle_renamed_attributes!
|
|
155
186
|
end
|
|
156
187
|
|
|
157
188
|
def validate_url_and_link_exclusivity!
|
|
@@ -167,17 +198,6 @@ module Html2rss
|
|
|
167
198
|
@selectors[:url] = @selectors[:link]
|
|
168
199
|
end
|
|
169
200
|
|
|
170
|
-
def handle_renamed_attributes!
|
|
171
|
-
RENAMED_ATTRIBUTES.each_pair do |new_name, old_names|
|
|
172
|
-
old_names.each do |old_name|
|
|
173
|
-
next unless @selectors.key?(old_name)
|
|
174
|
-
|
|
175
|
-
Html2rss::Log.warn("Selector '#{old_name}' is deprecated. Please rename to '#{new_name}'.")
|
|
176
|
-
@selectors[new_name] ||= @selectors.delete(old_name)
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
201
|
def parsed_body
|
|
182
202
|
parsed_body_for(response)
|
|
183
203
|
end
|
|
@@ -192,64 +212,58 @@ module Html2rss
|
|
|
192
212
|
end
|
|
193
213
|
end
|
|
194
214
|
|
|
195
|
-
def select_special(name,
|
|
215
|
+
def select_special(name, scope:, config:)
|
|
196
216
|
case name
|
|
197
217
|
when :enclosure
|
|
198
|
-
enclosure(
|
|
218
|
+
enclosure(scope:, config:)
|
|
199
219
|
when :guid
|
|
200
|
-
Array(config).map { |selector_name| select(selector_name
|
|
220
|
+
Array(config).map { |selector_name| scope.select(selector_name) }
|
|
201
221
|
when :categories
|
|
202
|
-
select_categories(category_selectors: config,
|
|
222
|
+
select_categories(category_selectors: config, scope:)
|
|
203
223
|
end
|
|
204
224
|
end
|
|
205
225
|
|
|
206
|
-
def select_regular(_name,
|
|
226
|
+
def select_regular(_name, scope:, config:)
|
|
207
227
|
@merged_configs ||= {}
|
|
208
|
-
merged_config = @merged_configs[[config.object_id, base_url]] ||=
|
|
209
|
-
config.merge(channel:
|
|
210
|
-
value = Extractors.get(merged_config, item)
|
|
228
|
+
merged_config = @merged_configs[[config.object_id, scope.base_url]] ||=
|
|
229
|
+
config.merge(channel: scope.channel).freeze
|
|
230
|
+
value = Extractors.get(merged_config, scope.item)
|
|
211
231
|
|
|
212
232
|
if value && (post_process_steps = config[:post_process])
|
|
213
233
|
steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
|
|
214
|
-
value = post_process(
|
|
234
|
+
value = post_process(scope, value, steps)
|
|
215
235
|
end
|
|
216
236
|
|
|
217
237
|
value
|
|
218
238
|
end
|
|
219
239
|
|
|
220
|
-
def post_process(
|
|
221
|
-
pp_context = channel_post_process_context(base_url)
|
|
240
|
+
def post_process(scope, value, post_process_steps)
|
|
222
241
|
post_process_steps.each do |options|
|
|
223
|
-
|
|
224
|
-
item:, scraper: self, options:)
|
|
225
|
-
|
|
226
|
-
value = PostProcessors.get(options[:name], value, context)
|
|
242
|
+
value = PostProcessors.get(options[:name], value, scope.context_for(options:))
|
|
227
243
|
end
|
|
228
244
|
|
|
229
245
|
value
|
|
230
246
|
end
|
|
231
247
|
|
|
232
|
-
def select_categories(category_selectors:,
|
|
248
|
+
def select_categories(category_selectors:, scope:)
|
|
233
249
|
Array(category_selectors).flat_map do |selector_name|
|
|
234
|
-
extract_category_values(selector_name,
|
|
250
|
+
extract_category_values(selector_name, scope:)
|
|
235
251
|
end
|
|
236
252
|
end
|
|
237
253
|
|
|
238
|
-
def extract_category_values(selector_name,
|
|
254
|
+
def extract_category_values(selector_name, scope:)
|
|
239
255
|
selector_key, config = selector_config_for(selector_name, allow_nil: true)
|
|
240
256
|
return [] unless config
|
|
241
257
|
|
|
242
|
-
nodes = extract_nodes(item
|
|
243
|
-
unless node_set_with_multiple_elements?(nodes)
|
|
244
|
-
return Array(select_regular(selector_key, item:, config:, base_url:))
|
|
245
|
-
end
|
|
258
|
+
nodes = extract_nodes(item: scope.item, config:)
|
|
259
|
+
return Array(select_regular(selector_key, scope:, config:)) unless node_set_with_multiple_elements?(nodes)
|
|
246
260
|
|
|
247
|
-
Array(nodes).flat_map { |node| extract_categories_from_node(node,
|
|
261
|
+
Array(nodes).flat_map { |node| extract_categories_from_node(node, scope:, config:) }
|
|
248
262
|
end
|
|
249
263
|
|
|
250
|
-
def extract_categories_from_node(node,
|
|
251
|
-
values = Extractors.get(category_node_options(config,
|
|
252
|
-
values = apply_post_process_steps(
|
|
264
|
+
def extract_categories_from_node(node, scope:, config:)
|
|
265
|
+
values = Extractors.get(category_node_options(config, scope:), node)
|
|
266
|
+
values = apply_post_process_steps(scope:, value: values, post_process_steps: config[:post_process])
|
|
253
267
|
|
|
254
268
|
Array(values).filter_map { |category| extract_category_text(category) }
|
|
255
269
|
end
|
|
@@ -257,7 +271,7 @@ module Html2rss
|
|
|
257
271
|
def extract_category_text(category)
|
|
258
272
|
text = case category
|
|
259
273
|
when Nokogiri::XML::Node, Nokogiri::XML::NodeSet
|
|
260
|
-
|
|
274
|
+
Html2rss::Html::Navigator.extract_visible_text(category)
|
|
261
275
|
else
|
|
262
276
|
category&.to_s
|
|
263
277
|
end
|
|
@@ -270,19 +284,19 @@ module Html2rss
|
|
|
270
284
|
nodes.is_a?(Nokogiri::XML::NodeSet) && nodes.length > 1
|
|
271
285
|
end
|
|
272
286
|
|
|
273
|
-
def category_node_options(selector_config,
|
|
287
|
+
def category_node_options(selector_config, scope:)
|
|
274
288
|
@category_node_configs ||= {}
|
|
275
|
-
@category_node_configs[[selector_config.object_id, base_url]] ||= selector_config.merge(
|
|
276
|
-
channel:
|
|
289
|
+
@category_node_configs[[selector_config.object_id, scope.base_url]] ||= selector_config.merge(
|
|
290
|
+
channel: scope.channel,
|
|
277
291
|
selector: nil
|
|
278
292
|
).freeze
|
|
279
293
|
end
|
|
280
294
|
|
|
281
|
-
def apply_post_process_steps(
|
|
295
|
+
def apply_post_process_steps(scope:, value:, post_process_steps:)
|
|
282
296
|
return value unless value && post_process_steps
|
|
283
297
|
|
|
284
298
|
steps = post_process_steps.is_a?(Array) ? post_process_steps : [post_process_steps]
|
|
285
|
-
post_process(
|
|
299
|
+
post_process(scope, value, steps)
|
|
286
300
|
end
|
|
287
301
|
|
|
288
302
|
def selector_config_for(name, allow_nil: false)
|
|
@@ -305,14 +319,20 @@ module Html2rss
|
|
|
305
319
|
@channel_contexts[base_url] ||= { url: base_url, time_zone: @time_zone }.freeze
|
|
306
320
|
end
|
|
307
321
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
322
|
+
# Keep a single enclosure Hash as one list entry; +Array(hash)+ would split pairs.
|
|
323
|
+
#
|
|
324
|
+
# @param value [Hash, Array] enclosure hash or list of hashes
|
|
325
|
+
# @return [Array]
|
|
326
|
+
def wrap_enclosure_value(value)
|
|
327
|
+
value.is_a?(Array) ? value : [value]
|
|
311
328
|
end
|
|
312
329
|
|
|
313
|
-
# @return [Hash] enclosure details.
|
|
314
|
-
def enclosure(
|
|
315
|
-
|
|
330
|
+
# @return [Hash, nil] enclosure details, or nil when the selector yields nothing.
|
|
331
|
+
def enclosure(scope:, config:)
|
|
332
|
+
selected = select_regular(:enclosure, scope:, config:)
|
|
333
|
+
return if selected.nil? || selected.to_s.strip.empty?
|
|
334
|
+
|
|
335
|
+
url = Url.from_relative(selected, scope.base_url)
|
|
316
336
|
|
|
317
337
|
{ url:, type: config[:content_type] }
|
|
318
338
|
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
|
|
6
|
+
#
|
|
7
|
+
# Exposed publicly via {FeedResult#status}. Safe to log without reading articles.
|
|
8
|
+
# Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
|
|
9
|
+
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
10
|
+
Status = Data.define(
|
|
11
|
+
:version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
|
|
12
|
+
) do
|
|
13
|
+
class << self
|
|
14
|
+
##
|
|
15
|
+
# Builds status from extracted articles and scrape telemetry.
|
|
16
|
+
#
|
|
17
|
+
# @param articles [Array<Html2rss::Article>] articles kept after deduplication
|
|
18
|
+
# @param dedup_dropped [Integer] number of articles removed by deduplication
|
|
19
|
+
# @param selected_strategy [Symbol, nil] concrete strategy that succeeded under +:auto+ (else +nil+)
|
|
20
|
+
# @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
|
|
21
|
+
# @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
|
|
22
|
+
# @return [Html2rss::Status]
|
|
23
|
+
def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0, strategy_attempts: [])
|
|
24
|
+
tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
|
|
25
|
+
new(
|
|
26
|
+
version: Html2rss::VERSION,
|
|
27
|
+
scraper_tallies: tallies,
|
|
28
|
+
dedup_dropped:,
|
|
29
|
+
selected_strategy:,
|
|
30
|
+
attempt_count:,
|
|
31
|
+
strategy_attempts:
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @param klass [Class, #to_s] scraper class
|
|
37
|
+
# @return [String] short scraper label for tallies / generator text
|
|
38
|
+
def scraper_name(klass)
|
|
39
|
+
klass.to_s.gsub(/(?:Html2rss|Scraper)::/, '')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @param version [String]
|
|
45
|
+
# @param scraper_tallies [Hash{String => Integer}]
|
|
46
|
+
# @param dedup_dropped [Integer]
|
|
47
|
+
# @param selected_strategy [Symbol, nil]
|
|
48
|
+
# @param attempt_count [Integer]
|
|
49
|
+
# @param strategy_attempts [Array<Hash>]
|
|
50
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
|
|
51
|
+
def initialize(
|
|
52
|
+
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
53
|
+
)
|
|
54
|
+
dedup = Integer(dedup_dropped)
|
|
55
|
+
attempts = Integer(attempt_count)
|
|
56
|
+
validate_counters!(dedup:, attempts:, selected_strategy:)
|
|
57
|
+
|
|
58
|
+
super(
|
|
59
|
+
version: version.to_s.dup.freeze,
|
|
60
|
+
scraper_tallies: freeze_tallies(scraper_tallies),
|
|
61
|
+
dedup_dropped: dedup,
|
|
62
|
+
selected_strategy:,
|
|
63
|
+
attempt_count: attempts,
|
|
64
|
+
strategy_attempts: freeze_attempts(strategy_attempts)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
|
|
71
|
+
# +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
|
|
72
|
+
# +:strategy_attempts+ when empty.
|
|
73
|
+
# Data members remain available via readers even when omitted here.
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
|
|
76
|
+
# optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+, +:strategy_attempts+
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
version:,
|
|
80
|
+
dedup_dropped:,
|
|
81
|
+
**(scraper_tallies.any? ? { scraper_tallies: } : {}),
|
|
82
|
+
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
83
|
+
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
84
|
+
**(strategy_attempts.any? ? { strategy_attempts: } : {})
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# Formats the RSS +generator+ string and JSON Feed +user_comment+.
|
|
90
|
+
# Scraper-focused only — auto strategy summary stays on {#to_h}, not this string.
|
|
91
|
+
# Omits the +(scrapers: …)+ clause when tallies are empty.
|
|
92
|
+
#
|
|
93
|
+
# @return [String]
|
|
94
|
+
def to_generator_comment
|
|
95
|
+
return "html2rss V. #{version}" if scraper_tallies.empty?
|
|
96
|
+
|
|
97
|
+
counts = scraper_tallies.map { |name, count| "#{name} (#{count})" }
|
|
98
|
+
"html2rss V. #{version} (scrapers: #{counts.join(', ')})"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def marshal_dump
|
|
104
|
+
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts))
|
|
108
|
+
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:, strategy_attempts:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def freeze_tallies(tallies)
|
|
112
|
+
tallies.to_h.transform_keys(&:to_s).transform_values { |count| Integer(count) }.freeze
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def freeze_attempts(attempts)
|
|
116
|
+
Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_counters!(dedup:, attempts:, selected_strategy:)
|
|
120
|
+
raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
|
|
121
|
+
raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
|
|
122
|
+
|
|
123
|
+
unless selected_strategy.nil? || selected_strategy.is_a?(Symbol)
|
|
124
|
+
raise ArgumentError, 'selected_strategy must be a Symbol or nil'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
return unless selected_strategy && attempts < 1
|
|
128
|
+
|
|
129
|
+
raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set'
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
data/lib/html2rss/version.rb
CHANGED
data/lib/html2rss.rb
CHANGED
|
@@ -8,7 +8,7 @@ loader.setup
|
|
|
8
8
|
|
|
9
9
|
require 'logger'
|
|
10
10
|
require 'forwardable'
|
|
11
|
-
require 'html2rss/
|
|
11
|
+
require 'html2rss/defaults'
|
|
12
12
|
|
|
13
13
|
##
|
|
14
14
|
# The Html2rss namespace.
|
|
@@ -34,22 +34,34 @@ module Html2rss
|
|
|
34
34
|
Config.load_yaml(file, feed_name)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
##
|
|
38
|
+
# Returns an opaque, Marshal-cacheable result of one scrape.
|
|
39
|
+
#
|
|
40
|
+
# Prefer this when the same scrape must render as RSS and JSON Feed (e.g. web cache).
|
|
41
|
+
#
|
|
42
|
+
# @param raw_config [Hash{Symbol => Object}] feed configuration
|
|
43
|
+
# @return [Html2rss::FeedResult]
|
|
44
|
+
def self.feed_result(raw_config)
|
|
45
|
+
FeedPipeline.new(raw_config).to_result
|
|
46
|
+
end
|
|
47
|
+
|
|
37
48
|
##
|
|
38
49
|
# Returns an RSS object generated from the provided configuration.
|
|
39
50
|
#
|
|
40
51
|
# @param raw_config [Hash{Symbol => Object}] feed configuration
|
|
41
52
|
# @return [RSS::Rss] generated RSS feed
|
|
42
53
|
def self.feed(raw_config)
|
|
43
|
-
|
|
54
|
+
feed_result(raw_config).to_rss
|
|
44
55
|
end
|
|
45
56
|
|
|
46
57
|
##
|
|
47
58
|
# Returns a JSONFeed 1.1 hash generated from the provided configuration.
|
|
48
59
|
#
|
|
49
60
|
# @param raw_config [Hash{Symbol => Object}] feed configuration
|
|
61
|
+
# @param feed_url [String, nil] optional self URL for the feed (JSON Feed +feed_url+)
|
|
50
62
|
# @return [Hash] JSONFeed-compliant hash
|
|
51
|
-
def self.json_feed(raw_config)
|
|
52
|
-
|
|
63
|
+
def self.json_feed(raw_config, feed_url: nil)
|
|
64
|
+
feed_result(raw_config).to_json_feed(feed_url:)
|
|
53
65
|
end
|
|
54
66
|
|
|
55
67
|
# rubocop:disable Metrics/ParameterLists
|
|
@@ -61,14 +73,14 @@ module Html2rss
|
|
|
61
73
|
# @param strategy [Symbol] request strategy to use
|
|
62
74
|
# @param items_selector [String, nil] optional selector hint for item extraction
|
|
63
75
|
# @param max_redirects [Integer, nil] optional redirect limit override
|
|
64
|
-
# @param max_requests [Integer
|
|
76
|
+
# @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
|
|
65
77
|
# @param local_file_path [String, nil] optional local HTML file path
|
|
66
78
|
# @return [RSS::Rss] generated RSS feed
|
|
67
79
|
def self.auto_source(url,
|
|
68
80
|
strategy: :auto,
|
|
69
81
|
items_selector: nil,
|
|
70
82
|
max_redirects: nil,
|
|
71
|
-
max_requests:
|
|
83
|
+
max_requests: 4,
|
|
72
84
|
local_file_path: nil)
|
|
73
85
|
feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:, local_file_path:))
|
|
74
86
|
end
|
|
@@ -80,14 +92,14 @@ module Html2rss
|
|
|
80
92
|
# @param strategy [Symbol] request strategy to use
|
|
81
93
|
# @param items_selector [String, nil] optional selector hint for item extraction
|
|
82
94
|
# @param max_redirects [Integer, nil] optional redirect limit override
|
|
83
|
-
# @param max_requests [Integer
|
|
95
|
+
# @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
|
|
84
96
|
# @param local_file_path [String, nil] optional local HTML file path
|
|
85
97
|
# @return [Hash] JSONFeed-compliant hash
|
|
86
98
|
def self.auto_json_feed(url,
|
|
87
99
|
strategy: :auto,
|
|
88
100
|
items_selector: nil,
|
|
89
101
|
max_redirects: nil,
|
|
90
|
-
max_requests:
|
|
102
|
+
max_requests: 4,
|
|
91
103
|
local_file_path: nil)
|
|
92
104
|
json_feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
93
105
|
local_file_path:))
|
|
@@ -98,26 +110,26 @@ module Html2rss
|
|
|
98
110
|
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
99
111
|
class << self
|
|
100
112
|
##
|
|
101
|
-
# @return [Html2rss::
|
|
102
|
-
def
|
|
103
|
-
@
|
|
113
|
+
# @return [Html2rss::Defaults] the global defaults instance
|
|
114
|
+
def defaults
|
|
115
|
+
@defaults ||= Defaults.new.freeze
|
|
104
116
|
end
|
|
105
117
|
|
|
106
118
|
##
|
|
107
119
|
# Configures global library defaults.
|
|
108
120
|
#
|
|
109
|
-
# @yieldparam config [Html2rss::
|
|
110
|
-
# @return [Html2rss::
|
|
121
|
+
# @yieldparam config [Html2rss::Defaults]
|
|
122
|
+
# @return [Html2rss::Defaults] the frozen defaults
|
|
111
123
|
def configure
|
|
112
|
-
config =
|
|
124
|
+
config = defaults.dup
|
|
113
125
|
yield config
|
|
114
|
-
@
|
|
126
|
+
@defaults = config.freeze
|
|
115
127
|
end
|
|
116
128
|
|
|
117
129
|
##
|
|
118
130
|
# @return [Object] the logger
|
|
119
131
|
def logger
|
|
120
|
-
|
|
132
|
+
defaults.logger
|
|
121
133
|
end
|
|
122
134
|
|
|
123
135
|
##
|
|
@@ -129,12 +141,12 @@ module Html2rss
|
|
|
129
141
|
private
|
|
130
142
|
|
|
131
143
|
##
|
|
132
|
-
# Resets the global
|
|
144
|
+
# Resets the global defaults (mainly for testing).
|
|
133
145
|
#
|
|
134
146
|
# @return [void]
|
|
135
|
-
def
|
|
136
|
-
@
|
|
137
|
-
logger.level =
|
|
147
|
+
def reset_defaults!
|
|
148
|
+
@defaults = nil
|
|
149
|
+
logger.level = defaults.log_level if logger.respond_to?(:level=)
|
|
138
150
|
end
|
|
139
151
|
end
|
|
140
152
|
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
@@ -156,7 +168,7 @@ module Html2rss
|
|
|
156
168
|
end
|
|
157
169
|
|
|
158
170
|
def shortcut_request_controls(strategy:, max_redirects:, max_requests:)
|
|
159
|
-
RequestControls.new(
|
|
171
|
+
Config::RequestControls.new(
|
|
160
172
|
strategy:,
|
|
161
173
|
max_redirects:,
|
|
162
174
|
max_requests:,
|
|
@@ -173,7 +185,7 @@ module Html2rss
|
|
|
173
185
|
end
|
|
174
186
|
end
|
|
175
187
|
|
|
176
|
-
logger.level =
|
|
188
|
+
logger.level = defaults.log_level if logger.respond_to?(:level=)
|
|
177
189
|
end
|
|
178
190
|
|
|
179
191
|
loader.eager_load
|