html2rss 0.32.4 → 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 (105) 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 +7 -3
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  48. data/lib/html2rss/feed_resolution/options.rb +3 -7
  49. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  50. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  51. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  52. data/lib/html2rss/feed_resolution.rb +5 -9
  53. data/lib/html2rss/feed_result.rb +4 -3
  54. data/lib/html2rss/hash_util.rb +91 -90
  55. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  56. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  57. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  59. data/lib/html2rss/html/probe.rb +51 -51
  60. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  61. data/lib/html2rss/mcp/config_argument.rb +9 -2
  62. data/lib/html2rss/mcp/contract.rb +7 -5
  63. data/lib/html2rss/mcp/outcome.rb +4 -5
  64. data/lib/html2rss/mcp/runtime.rb +17 -24
  65. data/lib/html2rss/mcp/server/tools.rb +9 -9
  66. data/lib/html2rss/option.rb +48 -0
  67. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  68. data/lib/html2rss/page_recon.rb +0 -4
  69. data/lib/html2rss/recon.rb +10 -12
  70. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  71. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  72. data/lib/html2rss/request_service/budget.rb +3 -24
  73. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  74. data/lib/html2rss/request_service/context.rb +1 -3
  75. data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
  76. data/lib/html2rss/request_service/network_guard.rb +3 -2
  77. data/lib/html2rss/request_service/policy.rb +8 -26
  78. data/lib/html2rss/request_service/response.rb +3 -6
  79. data/lib/html2rss/request_session/pager/base.rb +3 -3
  80. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  81. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  82. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  83. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  84. data/lib/html2rss/scoring/engine.rb +4 -32
  85. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  86. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  87. data/lib/html2rss/selectors/json_xml.rb +33 -34
  88. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  89. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  90. data/lib/html2rss/selectors/schema_export.rb +60 -60
  91. data/lib/html2rss/selectors.rb +14 -18
  92. data/lib/html2rss/sst/node.rb +9 -3
  93. data/lib/html2rss/sst/normalizer.rb +25 -24
  94. data/lib/html2rss/sst/text.rb +53 -57
  95. data/lib/html2rss/status.rb +23 -30
  96. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  97. data/lib/html2rss/syndication/discovery.rb +117 -187
  98. data/lib/html2rss/syndication/parser.rb +92 -115
  99. data/lib/html2rss/test/enhance_audit.rb +13 -15
  100. data/lib/html2rss/test/policy.rb +9 -11
  101. data/lib/html2rss/test.rb +29 -48
  102. data/lib/html2rss/url.rb +53 -53
  103. data/lib/html2rss/version.rb +1 -1
  104. data/lib/html2rss.rb +2 -1
  105. metadata +4 -2
@@ -14,48 +14,47 @@ module Html2rss
14
14
  array: ['<array>', '</array>']
15
15
  }.freeze
16
16
 
17
- module_function
18
-
19
- ##
20
- # Converts the object to XML format.
21
- #
22
- # @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
23
- # @return [String] representing the object in XML
24
- def call(object)
25
- object_to_xml(object).tap do |converted|
26
- Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
17
+ class << self
18
+ ##
19
+ # Converts the object to XML format.
20
+ #
21
+ # @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
22
+ # @return [String] representing the object in XML
23
+ def call(object)
24
+ object_to_xml(object).tap do |converted|
25
+ Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
26
+ end
27
27
  end
28
- end
29
28
 
30
- def object_to_xml(object)
31
- case object
32
- when Hash
33
- hash_to_xml(object)
34
- when Array
35
- array_to_xml(object)
36
- else
37
- CGI.escapeHTML(object.to_s)
29
+ private
30
+
31
+ def object_to_xml(object)
32
+ case object
33
+ when Hash
34
+ hash_to_xml(object)
35
+ when Array
36
+ array_to_xml(object)
37
+ else
38
+ CGI.escapeHTML(object.to_s)
39
+ end
38
40
  end
39
- end
40
- private_class_method :object_to_xml
41
41
 
42
- def hash_to_xml(object)
43
- prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
44
- inner_xml = object.each_with_object(+'') do |(key, value), str|
45
- str << "<#{key}>#{object_to_xml(value)}</#{key}>"
46
- end
42
+ def hash_to_xml(object)
43
+ prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
44
+ inner_xml = object.each_with_object(+'') do |(key, value), str|
45
+ str << "<#{key}>#{object_to_xml(value)}</#{key}>"
46
+ end
47
47
 
48
- "#{prefix}#{inner_xml}#{suffix}"
49
- end
50
- private_class_method :hash_to_xml
48
+ "#{prefix}#{inner_xml}#{suffix}"
49
+ end
51
50
 
52
- def array_to_xml(object)
53
- prefix, suffix = OBJECT_TO_XML_TAGS[:array]
54
- inner_xml = object.each_with_object(+'') { |value, str| str << object_to_xml(value) }
51
+ def array_to_xml(object)
52
+ prefix, suffix = OBJECT_TO_XML_TAGS[:array]
53
+ inner_xml = object.each_with_object(+'') { |value, str| str << object_to_xml(value) }
55
54
 
56
- "#{prefix}#{inner_xml}#{suffix}"
55
+ "#{prefix}#{inner_xml}#{suffix}"
56
+ end
57
57
  end
58
- private_class_method :array_to_xml
59
58
  end
60
59
  end
61
60
  end
@@ -101,7 +101,7 @@ module Html2rss
101
101
 
102
102
  source = regexp_source(string)
103
103
 
104
- expression = Regexp::Parser.parse(source, options: ::Regexp::EXTENDED | ::Regexp::IGNORECASE)
104
+ expression = Regexp::Parser.parse(source)
105
105
  raise ArgumentError, 'pattern contains nested quantifiers' if nested_quantifiers?(expression)
106
106
 
107
107
  expression.to_re
@@ -161,8 +161,7 @@ module Html2rss
161
161
  ##
162
162
  # @return [Regexp]
163
163
  def pattern
164
- raw = @pattern
165
- raw.is_a?(String) ? self.class.compiled_pattern(raw) : raw
164
+ self.class.compiled_pattern(@pattern)
166
165
  end
167
166
  end
168
167
  end
@@ -67,11 +67,10 @@ module Html2rss
67
67
  def self.validate_args!(_value, context)
68
68
  time_zone_value = context.time_zone
69
69
 
70
- if time_zone_value.nil? || time_zone_value.empty?
71
- raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil
72
- end
70
+ raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.nil?
73
71
 
74
72
  assert_type(time_zone_value, String, :time_zone, context:)
73
+ raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.empty?
75
74
  end
76
75
 
77
76
  ##
@@ -89,7 +88,7 @@ module Html2rss
89
88
  private
90
89
 
91
90
  def parse_time(string, time_zone)
92
- components = Date._parse(string, false)
91
+ components = Date._parse(string)
93
92
  if components[:zone] || components.key?(:offset)
94
93
  Time.parse(string)
95
94
  else
@@ -104,7 +103,7 @@ module Html2rss
104
103
  components[:mday],
105
104
  components[:hour] || 0,
106
105
  components[:min] || 0,
107
- (components[:sec] || 0) + (components[:sec_fraction] || 0)
106
+ components[:sec] || 0
108
107
  )
109
108
  end
110
109
  end
@@ -8,72 +8,72 @@ module Html2rss
8
8
  # Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTIONS+;
9
9
  # this module is the export adapter only.
10
10
  module SchemaExport
11
- module_function
12
-
13
- ##
14
- # @param name [Symbol, String] registry key
15
- # @param klass [Class] post-processor class
16
- # @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
17
- def for_post_processor(name:, klass:)
18
- name = name.to_s
19
- {
20
- type: 'object',
21
- title: name,
22
- description: klass::DESCRIPTION,
23
- examples: klass::EXAMPLES,
24
- properties: post_processor_properties(name, klass),
25
- required: post_processor_required(klass),
26
- additionalProperties: true
27
- }
28
- end
11
+ class << self
12
+ ##
13
+ # @param name [Symbol, String] registry key
14
+ # @param klass [Class] post-processor class
15
+ # @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
16
+ def for_post_processor(name:, klass:)
17
+ name = name.to_s
18
+ {
19
+ type: 'object',
20
+ title: name,
21
+ description: klass::DESCRIPTION,
22
+ examples: klass::EXAMPLES,
23
+ properties: post_processor_properties(name, klass),
24
+ required: post_processor_required(klass),
25
+ additionalProperties: true
26
+ }
27
+ end
29
28
 
30
- ##
31
- # @param name [Symbol, String] registry key
32
- # @param klass [Class] extractor class
33
- # @return [Hash{Symbol => Object}] JSON Schema for one extractor name
34
- def for_extractor(name:, klass:)
35
- name = name.to_s
36
- {
37
- type: 'string',
38
- const: name,
39
- title: name,
40
- description: klass::DESCRIPTION,
41
- examples: klass::EXAMPLES
42
- }
43
- end
29
+ ##
30
+ # @param name [Symbol, String] registry key
31
+ # @param klass [Class] extractor class
32
+ # @return [Hash{Symbol => Object}] JSON Schema for one extractor name
33
+ def for_extractor(name:, klass:)
34
+ name = name.to_s
35
+ {
36
+ type: 'string',
37
+ const: name,
38
+ title: name,
39
+ description: klass::DESCRIPTION,
40
+ examples: klass::EXAMPLES
41
+ }
42
+ end
44
43
 
45
- ##
46
- # @param name [String] registry key
47
- # @param klass [Class]
48
- # @return [Hash{Symbol => Hash}]
49
- def post_processor_properties(name, klass)
50
- properties = { name: { type: 'string', const: name } }
51
- OptionSpec.for(klass).each do |spec|
52
- properties[spec.name] = property_schema_for(spec.type)
44
+ ##
45
+ # @param name [String] registry key
46
+ # @param klass [Class]
47
+ # @return [Hash{Symbol => Hash}]
48
+ def post_processor_properties(name, klass)
49
+ properties = { name: { type: 'string', const: name } }
50
+ OptionSpec.for(klass).each do |spec|
51
+ properties[spec.name] = property_schema_for(spec.type)
52
+ end
53
+ properties
53
54
  end
54
- properties
55
- end
56
55
 
57
- ##
58
- # @param klass [Class]
59
- # @return [Array<String>]
60
- def post_processor_required(klass)
61
- required = ['name']
62
- required + OptionSpec.for(klass).select(&:required).map { |spec| spec.name.to_s }
63
- end
56
+ ##
57
+ # @param klass [Class]
58
+ # @return [Array<String>]
59
+ def post_processor_required(klass)
60
+ required = ['name']
61
+ required + OptionSpec.for(klass).select(&:required).map { |spec| spec.name.to_s }
62
+ end
64
63
 
65
- ##
66
- # @param ruby_type [Class, Array<Class>]
67
- # @return [Hash{Symbol => String, Array<String>}]
68
- def property_schema_for(ruby_type)
69
- { type: json_type_for(ruby_type) }
70
- end
64
+ ##
65
+ # @param ruby_type [Class, Array<Class>]
66
+ # @return [Hash{Symbol => String, Array<String>}]
67
+ def property_schema_for(ruby_type)
68
+ { type: json_type_for(ruby_type) }
69
+ end
71
70
 
72
- ##
73
- # @param ruby_type [Class, Array<Class>]
74
- # @return [String, Array<String>]
75
- def json_type_for(ruby_type)
76
- OptionSpec.json_type_for(ruby_type)
71
+ ##
72
+ # @param ruby_type [Class, Array<Class>]
73
+ # @return [String, Array<String>]
74
+ def json_type_for(ruby_type)
75
+ OptionSpec.json_type_for(ruby_type)
76
+ end
77
77
  end
78
78
  end
79
79
  end
@@ -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