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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +54 -1
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/README.md +1 -1
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
  48. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  49. data/lib/html2rss/feed_resolution/options.rb +3 -7
  50. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  51. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  52. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  53. data/lib/html2rss/feed_resolution.rb +5 -9
  54. data/lib/html2rss/feed_result.rb +4 -3
  55. data/lib/html2rss/hash_util.rb +91 -90
  56. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  59. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  60. data/lib/html2rss/html/probe.rb +51 -51
  61. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  62. data/lib/html2rss/mcp/config_argument.rb +9 -2
  63. data/lib/html2rss/mcp/contract.rb +7 -5
  64. data/lib/html2rss/mcp/outcome.rb +4 -5
  65. data/lib/html2rss/mcp/runtime.rb +17 -24
  66. data/lib/html2rss/mcp/server/tools.rb +9 -9
  67. data/lib/html2rss/option.rb +48 -0
  68. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  69. data/lib/html2rss/page_recon.rb +0 -4
  70. data/lib/html2rss/recon.rb +10 -12
  71. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  72. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  73. data/lib/html2rss/request_service/budget.rb +3 -24
  74. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  75. data/lib/html2rss/request_service/context.rb +1 -3
  76. data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
  77. data/lib/html2rss/request_service/network_guard.rb +3 -2
  78. data/lib/html2rss/request_service/policy.rb +8 -26
  79. data/lib/html2rss/request_service/response.rb +3 -6
  80. data/lib/html2rss/request_service/strategy.rb +2 -2
  81. data/lib/html2rss/request_service.rb +26 -2
  82. data/lib/html2rss/request_session/pager/base.rb +3 -3
  83. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  84. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  85. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  86. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  87. data/lib/html2rss/scoring/engine.rb +4 -32
  88. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  89. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  90. data/lib/html2rss/selectors/json_xml.rb +33 -34
  91. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  92. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  93. data/lib/html2rss/selectors/schema_export.rb +60 -60
  94. data/lib/html2rss/selectors.rb +14 -18
  95. data/lib/html2rss/sst/node.rb +9 -3
  96. data/lib/html2rss/sst/normalizer.rb +25 -24
  97. data/lib/html2rss/sst/text.rb +53 -57
  98. data/lib/html2rss/status.rb +23 -30
  99. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  100. data/lib/html2rss/syndication/discovery.rb +117 -187
  101. data/lib/html2rss/syndication/parser.rb +92 -115
  102. data/lib/html2rss/test/enhance_audit.rb +13 -15
  103. data/lib/html2rss/test/policy.rb +9 -11
  104. data/lib/html2rss/test.rb +29 -48
  105. data/lib/html2rss/url.rb +53 -53
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +2 -1
  108. metadata +4 -2
@@ -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:, config: nil) if name == :url
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
- def select_field(selector_key, scope:, config:)
229
- return default_item_url(scope.item, scope.base_url) if fallback_url_selector?(selector_key, config, scope.item)
230
- raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined." if config.nil?
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
- if SPECIAL_ATTRIBUTES.member?(selector_key)
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
- text = case category
379
- when Nokogiri::XML::Node, Nokogiri::XML::NodeSet
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)
@@ -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 [ArgumentError] when input is nil or unsupported
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 ArgumentError, 'input is required' if input.nil?
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 ArgumentError, "expected String or Nokogiri::XML::Node, got #{input.class}"
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, parent: nil, depth: 0, path: '', chrome: false)
104
- raise EmptyTree, 'SST Normalizer produced an empty tree' unless root
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, parent:, depth:, path:, chrome:)
150
+ def normalize_element(nk_node, depth:, path:, chrome:)
154
151
  return unless nk_node.element?
155
152
 
156
- tag = Html2rss::Html::Probe.tag(nk_node)
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
- Html2rss::Log.warn({
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 = path.empty? ? "/#{tag}" : "#{path}/#{tag}"
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.element_children.filter_map do |child|
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:, children:, tag_path:)
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
- def direct_text(nk_node)
217
- nk_node.children.select(&:text?).map(&:text).join
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
@@ -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
- module_function
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
- # @param node [Node]
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
- parts = collect_parts(node, separator, excluded)
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
- def collect_parts(node, separator, excluded)
33
- last_block = false
34
- node.children.each_with_object([]) do |child, parts|
35
- next if excluded&.[](child)
36
- next unless visible_child?(child)
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
- text, block = child_text_and_block(child, separator, excluded)
39
- next if text.empty?
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
- append_separator!(parts, separator, block, last_block)
42
- parts << text
43
- last_block = block
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
- def child_text_and_block(child, separator, excluded)
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
- def append_separator!(parts, separator, is_block, last_was_block)
62
- return if parts.empty?
48
+ def squish(text) = text.to_s.gsub(/\s+/, ' ').strip
63
49
 
64
- parts << if is_block || last_was_block
65
- (separator == ' ' ? "\n" : separator)
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
- end
70
- module_function :append_separator!
71
- private_class_method :append_separator!
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
- def visible_child?(node)
74
- !Tags::INVISIBLE_NAMES.include?(node.name) &&
75
- !(node.name == :a && node.attrs.href&.start_with?('#'))
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
@@ -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 = Integer(dedup_dropped)
75
- attempts = Integer(attempt_count)
76
- validate_counters!(dedup:, attempts:, selected_strategy:)
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: admission_drops || {}, entry_url:, scrape_url:,
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.transform_keys(&:to_s).transform_values { |count| Integer(count) }.freeze
141
+ tallies.to_h { |name, count| [name.to_s, count] }.freeze
142
142
  end
143
143
 
144
144
  def freeze_attempts(attempts)
145
- Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
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 validate_counters!(dedup:, attempts:, selected_strategy:)
165
- raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
166
- raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
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