html2rss 0.32.3 → 0.33.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/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +54 -1
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_service/strategy.rb +2 -2
- data/lib/html2rss/request_service.rb +26 -2
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -132,7 +132,7 @@ module Html2rss
|
|
|
132
132
|
|
|
133
133
|
field = @compiled_fields[name]
|
|
134
134
|
return select_compiled(field, scope) if field
|
|
135
|
-
return select_field(name, scope
|
|
135
|
+
return select_field(name, scope:) if name == :url
|
|
136
136
|
|
|
137
137
|
raise InvalidSelectorName, "Selector for '#{name}' is not defined."
|
|
138
138
|
end
|
|
@@ -225,15 +225,11 @@ module Html2rss
|
|
|
225
225
|
value.is_a?(Array) ? value : [value]
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
# Only reached for +:url+ without a configured selector, so the item must be an anchor.
|
|
229
|
+
def select_field(selector_key, scope:)
|
|
230
|
+
return default_item_url(scope.item, scope.base_url) if anchor_element?(scope.item)
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
select_special(selector_key, scope:, config:)
|
|
234
|
-
else
|
|
235
|
-
select_regular(selector_key, scope:, config:)
|
|
236
|
-
end
|
|
232
|
+
raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined."
|
|
237
233
|
end
|
|
238
234
|
|
|
239
235
|
def select_compiled(field, scope)
|
|
@@ -327,6 +323,7 @@ module Html2rss
|
|
|
327
323
|
end
|
|
328
324
|
|
|
329
325
|
def post_process(scope, value, post_process_steps)
|
|
326
|
+
value = step_input(value)
|
|
330
327
|
post_process_steps.each do |step_config|
|
|
331
328
|
value = PostProcessors.call(step_config[:name], value, scope.context_for(step_config:))
|
|
332
329
|
end
|
|
@@ -334,9 +331,15 @@ module Html2rss
|
|
|
334
331
|
value
|
|
335
332
|
end
|
|
336
333
|
|
|
334
|
+
# Post-processors work on text, so a URL value (from the href extractor) enters them as a String.
|
|
335
|
+
def step_input(value)
|
|
336
|
+
value.is_a?(Url) ? value.to_s : value
|
|
337
|
+
end
|
|
338
|
+
|
|
337
339
|
def apply_compiled_post_process(scope, value, steps)
|
|
338
340
|
return value unless value && steps
|
|
339
341
|
|
|
342
|
+
value = step_input(value)
|
|
340
343
|
steps.each do |klass, step_config|
|
|
341
344
|
value = klass.new(value, scope.context_for(step_config:)).call
|
|
342
345
|
end
|
|
@@ -375,15 +378,8 @@ module Html2rss
|
|
|
375
378
|
end
|
|
376
379
|
|
|
377
380
|
def extract_category_text(category)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
Html2rss::Html::Navigator.extract_visible_text(category)
|
|
381
|
-
else
|
|
382
|
-
category&.to_s
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
stripped = text&.strip
|
|
386
|
-
stripped unless stripped.nil? || stripped.empty?
|
|
381
|
+
stripped = category.to_s.strip
|
|
382
|
+
stripped unless stripped.empty?
|
|
387
383
|
end
|
|
388
384
|
|
|
389
385
|
def node_set_with_multiple_elements?(nodes)
|
data/lib/html2rss/sst/node.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module SST
|
|
5
|
-
Node = Data.define(:name, :attrs, :own_text, :children, :tag_path)
|
|
5
|
+
Node = Data.define(:name, :attrs, :own_text, :tails, :children, :tag_path)
|
|
6
6
|
|
|
7
7
|
##
|
|
8
8
|
# Immutable simplified semantic tree node. Predicates and traversal live on
|
|
@@ -11,12 +11,13 @@ module Html2rss
|
|
|
11
11
|
##
|
|
12
12
|
# @param name [Symbol, String]
|
|
13
13
|
# @param attrs [Attrs, nil]
|
|
14
|
-
# @param own_text [String, nil]
|
|
14
|
+
# @param own_text [String, nil] all direct text, in document order
|
|
15
|
+
# @param tails [Array<String>, nil] direct text following each child, parallel to +children+
|
|
15
16
|
# @param children [Array<Node>, nil]
|
|
16
17
|
# @param tag_path [String, nil] index-free ancestry path for list clustering
|
|
17
18
|
# @return [Node]
|
|
18
19
|
# @raise [ArgumentError] on invalid name/attrs/children
|
|
19
|
-
def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil)
|
|
20
|
+
def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil, tails: nil) # rubocop:disable Metrics/ParameterLists
|
|
20
21
|
attrs_value = coerce_attrs(attrs)
|
|
21
22
|
kids = coerce_children(children)
|
|
22
23
|
|
|
@@ -24,6 +25,7 @@ module Html2rss
|
|
|
24
25
|
name: coerce_name(name),
|
|
25
26
|
attrs: attrs_value,
|
|
26
27
|
own_text: own_text.to_s.freeze,
|
|
28
|
+
tails: Array(tails).map { _1.to_s.freeze }.freeze,
|
|
27
29
|
children: kids,
|
|
28
30
|
tag_path: tag_path.to_s.freeze
|
|
29
31
|
)
|
|
@@ -52,6 +54,10 @@ module Html2rss
|
|
|
52
54
|
end
|
|
53
55
|
private_class_method :coerce_children
|
|
54
56
|
|
|
57
|
+
##
|
|
58
|
+
# @return [String] direct text before the first child
|
|
59
|
+
def lead_text = own_text.delete_suffix(tails.join)
|
|
60
|
+
|
|
55
61
|
##
|
|
56
62
|
# @return [Boolean]
|
|
57
63
|
def link?
|
|
@@ -8,9 +8,6 @@ module Html2rss
|
|
|
8
8
|
# Sole Nokogiri consumer on the heuristic auto-source path. Builds an
|
|
9
9
|
# immutable SST::Document with parent/depth/chrome indices.
|
|
10
10
|
class Normalizer # rubocop:disable Metrics/ClassLength
|
|
11
|
-
# Raised when no SST nodes survive normalization for the chosen root.
|
|
12
|
-
class EmptyTree < ArgumentError; end
|
|
13
|
-
|
|
14
11
|
# Hard ceiling for SST node allocations; beyond this we degrade.
|
|
15
12
|
MAX_NODES = 5_000
|
|
16
13
|
|
|
@@ -64,10 +61,10 @@ module Html2rss
|
|
|
64
61
|
##
|
|
65
62
|
# @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
|
|
66
63
|
# HTML string or already-parsed node (String is parsed once here)
|
|
67
|
-
# @return [Document]
|
|
68
|
-
# @raise [
|
|
64
|
+
# @return [Document, nil] +nil+ when no SST nodes survive normalization
|
|
65
|
+
# @raise [InvalidOption] when input is nil or unsupported
|
|
69
66
|
def call(input)
|
|
70
|
-
raise
|
|
67
|
+
raise InvalidOption, 'input is required' if input.nil?
|
|
71
68
|
|
|
72
69
|
new(coerce(input)).call
|
|
73
70
|
end
|
|
@@ -81,7 +78,7 @@ module Html2rss
|
|
|
81
78
|
when String then Nokogiri::HTML(input)
|
|
82
79
|
when Nokogiri::XML::Node then input
|
|
83
80
|
else
|
|
84
|
-
raise
|
|
81
|
+
raise InvalidOption, "expected String or Nokogiri::XML::Node, got #{input.class}"
|
|
85
82
|
end
|
|
86
83
|
end
|
|
87
84
|
end
|
|
@@ -97,11 +94,11 @@ module Html2rss
|
|
|
97
94
|
end
|
|
98
95
|
|
|
99
96
|
##
|
|
100
|
-
# @return [Document]
|
|
97
|
+
# @return [Document, nil] +nil+ when no SST nodes survive normalization
|
|
101
98
|
def call
|
|
102
99
|
root_nk = resolve_root_nk
|
|
103
|
-
root = normalize_element(root_nk,
|
|
104
|
-
|
|
100
|
+
root = normalize_element(root_nk, depth: 0, path: '', chrome: false)
|
|
101
|
+
return unless root
|
|
105
102
|
|
|
106
103
|
index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
|
|
107
104
|
Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
|
|
@@ -150,15 +147,15 @@ module Html2rss
|
|
|
150
147
|
end
|
|
151
148
|
|
|
152
149
|
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
153
|
-
def normalize_element(nk_node,
|
|
150
|
+
def normalize_element(nk_node, depth:, path:, chrome:)
|
|
154
151
|
return unless nk_node.element?
|
|
155
152
|
|
|
156
|
-
tag =
|
|
153
|
+
tag = Html::Probe.tag(nk_node)
|
|
157
154
|
return if STRIPPED_TAGS.include?(tag)
|
|
158
155
|
|
|
159
156
|
if @node_count >= MAX_NODES && !@degraded
|
|
160
157
|
@degraded = true
|
|
161
|
-
|
|
158
|
+
Log.warn({
|
|
162
159
|
event_name: 'sst.normalizer.max_nodes',
|
|
163
160
|
max_nodes: MAX_NODES,
|
|
164
161
|
message: "sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only"
|
|
@@ -168,21 +165,15 @@ module Html2rss
|
|
|
168
165
|
return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
|
|
169
166
|
|
|
170
167
|
@node_count += 1
|
|
171
|
-
tag_path =
|
|
168
|
+
tag_path = "#{path}/#{tag}"
|
|
172
169
|
attrs = extract_attrs(nk_node)
|
|
173
|
-
own_text = direct_text(nk_node)
|
|
174
170
|
chrome_here = chrome || IGNORED_CONTAINER_TAGS.include?(tag)
|
|
175
171
|
|
|
176
|
-
children = nk_node
|
|
177
|
-
normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
|
|
178
|
-
end.freeze
|
|
172
|
+
children, lead, tails = normalize_children(nk_node, depth:, path: tag_path, chrome: chrome_here)
|
|
179
173
|
|
|
180
|
-
node = Node.build(name: tag, attrs:, own_text
|
|
181
|
-
@parents[node] = parent == :pending ? nil : parent
|
|
174
|
+
node = Node.build(name: tag, attrs:, own_text: [lead, *tails].join, children:, tag_path:, tails:)
|
|
182
175
|
@depths[node] = depth
|
|
183
176
|
@ignored_chrome[node] = chrome_here
|
|
184
|
-
|
|
185
|
-
# Fix parent pointers now that node exists.
|
|
186
177
|
children.each { |child| @parents[child] = node }
|
|
187
178
|
|
|
188
179
|
node
|
|
@@ -213,8 +204,18 @@ module Html2rss
|
|
|
213
204
|
end
|
|
214
205
|
end
|
|
215
206
|
|
|
216
|
-
|
|
217
|
-
|
|
207
|
+
# Walks child nodes once, keeping direct text in the slot of the preceding kept child
|
|
208
|
+
# (slot 0 is the lead text) so text order survives the element-only tree.
|
|
209
|
+
def normalize_children(nk_node, depth:, path:, chrome:)
|
|
210
|
+
children = []
|
|
211
|
+
slots = [+'']
|
|
212
|
+
nk_node.children.each do |child|
|
|
213
|
+
next slots.last << child.text if child.text?
|
|
214
|
+
|
|
215
|
+
node = normalize_element(child, depth: depth + 1, path:, chrome:)
|
|
216
|
+
(children << node) && (slots << +'') if node
|
|
217
|
+
end
|
|
218
|
+
[children, slots.first, slots.drop(1)]
|
|
218
219
|
end
|
|
219
220
|
end
|
|
220
221
|
end
|
data/lib/html2rss/sst/text.rb
CHANGED
|
@@ -5,77 +5,73 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# Visible-text extraction for SST nodes (port of Navigator::TextExtractor).
|
|
7
7
|
module Text
|
|
8
|
-
|
|
8
|
+
class << self
|
|
9
|
+
##
|
|
10
|
+
# @param node [Node]
|
|
11
|
+
# @param separator [String]
|
|
12
|
+
# @param exclude [Array<Node>, nil]
|
|
13
|
+
# @return [String, nil]
|
|
14
|
+
def extract(node, separator: ' ', exclude: nil)
|
|
15
|
+
raise ArgumentError, 'node is required' unless node
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# @param separator [String]
|
|
13
|
-
# @param exclude [Array<Node>, nil]
|
|
14
|
-
# @return [String, nil]
|
|
15
|
-
def extract(node, separator: ' ', exclude: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
16
|
-
raise ArgumentError, 'node is required' unless node
|
|
17
|
-
|
|
18
|
-
excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
|
|
19
|
-
if node.children.empty?
|
|
20
|
-
text = node.own_text.to_s.gsub(/\s+/, ' ').strip
|
|
21
|
-
return text.empty? ? nil : text
|
|
17
|
+
excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
|
|
18
|
+
join_fragments(fragments(node, separator, excluded), separator)
|
|
22
19
|
end
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
own = node.own_text.to_s.gsub(/\s+/, ' ').strip
|
|
26
|
-
parts = [own, *parts] unless own.empty?
|
|
27
|
-
return if parts.empty?
|
|
28
|
-
|
|
29
|
-
parts.join.squeeze(' ').strip
|
|
30
|
-
end
|
|
21
|
+
private
|
|
31
22
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
# Ordered [text, block?] pairs: lead text, then each child followed by the text after it.
|
|
24
|
+
def fragments(node, separator, excluded)
|
|
25
|
+
pairs = [[squish(node.lead_text), false]]
|
|
26
|
+
node.children.zip(node.tails).each do |child, tail|
|
|
27
|
+
pairs << child_text_and_block(child, separator, excluded) if shown?(child, excluded)
|
|
28
|
+
pairs << [squish(tail), false]
|
|
29
|
+
end
|
|
30
|
+
pairs
|
|
31
|
+
end
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
def join_fragments(pairs, separator)
|
|
34
|
+
parts = []
|
|
35
|
+
last_block = false
|
|
36
|
+
pairs.each do |text, block|
|
|
37
|
+
next if text.empty?
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
append_separator!(parts, separator, block, last_block)
|
|
40
|
+
parts << text
|
|
41
|
+
last_block = block
|
|
42
|
+
end
|
|
43
|
+
parts.join unless parts.empty?
|
|
44
44
|
end
|
|
45
|
-
end
|
|
46
|
-
module_function :collect_parts
|
|
47
|
-
private_class_method :collect_parts
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
text = if child.children.empty?
|
|
51
|
-
child.own_text.gsub(/\s+/, ' ').strip
|
|
52
|
-
else
|
|
53
|
-
extract(child, separator:, exclude: excluded&.keys).to_s.strip
|
|
54
|
-
end
|
|
55
|
-
text = "- #{text}" if child.name == :li && !text.empty?
|
|
56
|
-
[text, Tags::BLOCK_NAMES.include?(child.name)]
|
|
57
|
-
end
|
|
58
|
-
module_function :child_text_and_block
|
|
59
|
-
private_class_method :child_text_and_block
|
|
46
|
+
def shown?(child, excluded) = !excluded&.[](child) && visible_child?(child)
|
|
60
47
|
|
|
61
|
-
|
|
62
|
-
return if parts.empty?
|
|
48
|
+
def squish(text) = text.to_s.gsub(/\s+/, ' ').strip
|
|
63
49
|
|
|
64
|
-
|
|
65
|
-
|
|
50
|
+
def child_text_and_block(child, separator, excluded)
|
|
51
|
+
text = if child.children.empty?
|
|
52
|
+
squish(child.own_text)
|
|
66
53
|
else
|
|
67
|
-
|
|
54
|
+
extract(child, separator:, exclude: excluded&.keys).to_s.strip
|
|
68
55
|
end
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
56
|
+
text = "- #{text}" if child.name == :li && !text.empty?
|
|
57
|
+
[text, Tags::BLOCK_NAMES.include?(child.name)]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def append_separator!(parts, separator, is_block, last_was_block)
|
|
61
|
+
return if parts.empty?
|
|
72
62
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
63
|
+
parts << if is_block || last_was_block
|
|
64
|
+
(separator == ' ' ? "\n" : separator)
|
|
65
|
+
else
|
|
66
|
+
' '
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def visible_child?(node)
|
|
71
|
+
!Tags::INVISIBLE_NAMES.include?(node.name) &&
|
|
72
|
+
!(node.name == :a && node.attrs.href&.start_with?('#'))
|
|
73
|
+
end
|
|
76
74
|
end
|
|
77
|
-
module_function :visible_child?
|
|
78
|
-
private_class_method :visible_child?
|
|
79
75
|
end
|
|
80
76
|
end
|
|
81
77
|
end
|
data/lib/html2rss/status.rb
CHANGED
|
@@ -13,6 +13,10 @@ module Html2rss
|
|
|
13
13
|
# Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
|
|
14
14
|
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
15
15
|
class Status
|
|
16
|
+
# Keys kept in {#to_h} even when zero or empty.
|
|
17
|
+
ALWAYS_REPORTED = %i[version dedup_dropped].freeze
|
|
18
|
+
private_constant :ALWAYS_REPORTED
|
|
19
|
+
|
|
16
20
|
class << self
|
|
17
21
|
##
|
|
18
22
|
# Builds status from extracted articles and scrape telemetry.
|
|
@@ -71,9 +75,9 @@ module Html2rss
|
|
|
71
75
|
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
72
76
|
strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
|
|
73
77
|
)
|
|
74
|
-
dedup =
|
|
75
|
-
attempts =
|
|
76
|
-
|
|
78
|
+
dedup = Option.integer(:dedup_dropped, dedup_dropped, min: 0)
|
|
79
|
+
attempts = Option.integer(:attempt_count, attempt_count, min: 0)
|
|
80
|
+
validate_selected_strategy!(selected_strategy, attempts)
|
|
77
81
|
|
|
78
82
|
super(
|
|
79
83
|
version: version.to_s.dup.freeze,
|
|
@@ -93,20 +97,8 @@ module Html2rss
|
|
|
93
97
|
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
|
|
94
98
|
#
|
|
95
99
|
# @return [Hash{Symbol => Object}]
|
|
96
|
-
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
|
|
97
100
|
def to_h
|
|
98
|
-
{
|
|
99
|
-
version:,
|
|
100
|
-
dedup_dropped:,
|
|
101
|
-
**(scraper_tallies.any? ? { scraper_tallies: } : {}),
|
|
102
|
-
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
103
|
-
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
104
|
-
**(strategy_attempts.any? ? { strategy_attempts: } : {}),
|
|
105
|
-
**(admission_drops.any? ? { admission_drops: } : {}),
|
|
106
|
-
**(entry_url ? { entry_url: } : {}),
|
|
107
|
-
**(scrape_url ? { scrape_url: } : {}),
|
|
108
|
-
**(entry_resolution ? { entry_resolution: } : {})
|
|
109
|
-
}
|
|
101
|
+
super.reject { |key, value| !ALWAYS_REPORTED.include?(key) && omit?(value) }
|
|
110
102
|
end
|
|
111
103
|
|
|
112
104
|
##
|
|
@@ -124,6 +116,14 @@ module Html2rss
|
|
|
124
116
|
|
|
125
117
|
private
|
|
126
118
|
|
|
119
|
+
def omit?(value)
|
|
120
|
+
case value
|
|
121
|
+
when nil then true
|
|
122
|
+
when Integer then value.zero?
|
|
123
|
+
else value.empty?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
127
|
def marshal_dump
|
|
128
128
|
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
|
|
129
129
|
admission_drops, entry_url, scrape_url, entry_resolution]
|
|
@@ -133,16 +133,16 @@ module Html2rss
|
|
|
133
133
|
version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
|
|
134
134
|
strategy_attempts, admission_drops, entry_url, scrape_url, entry_resolution = payload
|
|
135
135
|
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
136
|
-
strategy_attempts:, admission_drops
|
|
136
|
+
strategy_attempts:, admission_drops:, entry_url:, scrape_url:,
|
|
137
137
|
entry_resolution:)
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
def freeze_tallies(tallies)
|
|
141
|
-
tallies.to_h
|
|
141
|
+
tallies.to_h { |name, count| [name.to_s, count] }.freeze
|
|
142
142
|
end
|
|
143
143
|
|
|
144
144
|
def freeze_attempts(attempts)
|
|
145
|
-
|
|
145
|
+
attempts.map { |attempt| attempt.dup.freeze }.freeze
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
def freeze_optional_string(value)
|
|
@@ -161,17 +161,10 @@ module Html2rss
|
|
|
161
161
|
end.transform_keys(&:to_sym).freeze
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
-
def
|
|
165
|
-
|
|
166
|
-
raise ArgumentError, '
|
|
167
|
-
|
|
168
|
-
unless selected_strategy.nil? || selected_strategy.is_a?(Symbol)
|
|
169
|
-
raise ArgumentError, 'selected_strategy must be a Symbol or nil'
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
return unless selected_strategy && attempts < 1
|
|
173
|
-
|
|
174
|
-
raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set'
|
|
164
|
+
def validate_selected_strategy!(selected_strategy, attempts)
|
|
165
|
+
return if selected_strategy.nil?
|
|
166
|
+
raise ArgumentError, 'selected_strategy must be a Symbol or nil' unless selected_strategy.is_a?(Symbol)
|
|
167
|
+
raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set' if attempts < 1
|
|
175
168
|
end
|
|
176
169
|
end
|
|
177
170
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Syndication
|
|
5
|
+
##
|
|
6
|
+
# Scans raw HTML for +<link rel="alternate">+ hints that point at RSS/Atom feeds.
|
|
7
|
+
module AlternateLinks
|
|
8
|
+
LINK_TAG_RE = /<link\b[^>]*>/i
|
|
9
|
+
HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/i
|
|
10
|
+
TYPE_RE = /\btype\s*=\s*["']([^"']+)["']/i
|
|
11
|
+
REL_RE = /\brel\s*=\s*["']([^"']+)["']/i
|
|
12
|
+
private_constant :LINK_TAG_RE, :HREF_RE, :TYPE_RE, :REL_RE
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
##
|
|
16
|
+
# @param html [String] raw HTML
|
|
17
|
+
# @return [Array<String>] hrefs of alternate links that look like feeds
|
|
18
|
+
def hrefs(html)
|
|
19
|
+
html.scan(LINK_TAG_RE).filter_map { |tag| feed_href(tag) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def feed_href(tag)
|
|
25
|
+
return unless tag[REL_RE, 1].to_s.downcase.split(/\s+/).include?('alternate')
|
|
26
|
+
|
|
27
|
+
href = tag[HREF_RE, 1]
|
|
28
|
+
return if href.nil? || href.empty?
|
|
29
|
+
|
|
30
|
+
href.strip if syndication_type?(tag[TYPE_RE, 1]) || href.match?(/rss|atom|feed|\.xml/i)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def syndication_type?(type)
|
|
34
|
+
type.to_s.downcase.match?(/rss|atom/)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|