html2rss 0.23.0 → 0.25.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article.rb +42 -35
  4. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  5. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  9. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  10. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  12. data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
  13. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  14. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  16. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  17. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  18. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  19. data/lib/html2rss/auto_source/scraper.rb +81 -44
  20. data/lib/html2rss/auto_source/segment.rb +29 -0
  21. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  22. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  23. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  24. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  25. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  26. data/lib/html2rss/auto_source.rb +74 -21
  27. data/lib/html2rss/channel.rb +159 -79
  28. data/lib/html2rss/cli.rb +8 -13
  29. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  30. data/lib/html2rss/config/request_controls.rb +33 -0
  31. data/lib/html2rss/config/validator.rb +15 -8
  32. data/lib/html2rss/config.rb +6 -2
  33. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  34. data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
  35. data/lib/html2rss/feed_builder/json_feed.rb +22 -2
  36. data/lib/html2rss/feed_builder/rss.rb +36 -24
  37. data/lib/html2rss/feed_builder.rb +9 -17
  38. data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
  39. data/lib/html2rss/feed_pipeline.rb +66 -55
  40. data/lib/html2rss/feed_result.rb +92 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
  62. data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
  63. data/lib/html2rss/request_service/budget.rb +7 -2
  64. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  65. data/lib/html2rss/request_service/network_guard.rb +5 -3
  66. data/lib/html2rss/request_service/response.rb +9 -1
  67. data/lib/html2rss/request_service/strategy.rb +22 -0
  68. data/lib/html2rss/request_session.rb +4 -0
  69. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  70. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  71. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  72. data/lib/html2rss/scoring/engine.rb +101 -0
  73. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  74. data/lib/html2rss/scoring/observation.rb +53 -0
  75. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  76. data/lib/html2rss/scoring/score.rb +30 -0
  77. data/lib/html2rss/scoring.rb +33 -0
  78. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  79. data/lib/html2rss/selectors.rb +26 -24
  80. data/lib/html2rss/sst/attrs.rb +91 -0
  81. data/lib/html2rss/sst/document.rb +23 -0
  82. data/lib/html2rss/sst/index.rb +112 -0
  83. data/lib/html2rss/sst/node.rb +147 -0
  84. data/lib/html2rss/sst/normalizer.rb +171 -0
  85. data/lib/html2rss/sst/tags.rb +26 -0
  86. data/lib/html2rss/sst/text.rb +81 -0
  87. data/lib/html2rss/sst.rb +8 -0
  88. data/lib/html2rss/status.rb +132 -0
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +41 -29
  91. data/schema/html2rss-config.schema.json +20 -0
  92. metadata +45 -19
  93. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  94. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  95. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  96. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  97. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  98. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  99. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  100. data/lib/html2rss/auto_source/discovery.rb +0 -14
  101. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  102. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  103. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  104. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  105. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  106. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  107. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  108. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  109. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module SST
5
+ ##
6
+ # Immutable simplified semantic tree node. Predicates and traversal live on
7
+ # the type so Scoring/Segmenter never touch Nokogiri.
8
+ Node = Data.define(:name, :attrs, :own_text, :children, :tag_path) do
9
+ ##
10
+ # @param name [Symbol, String]
11
+ # @param attrs [Attrs, nil]
12
+ # @param own_text [String, nil]
13
+ # @param children [Array<Node>, nil]
14
+ # @param tag_path [String, nil] index-free ancestry path for list clustering
15
+ # @return [Node]
16
+ # @raise [ArgumentError] on invalid name/attrs/children
17
+ def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil)
18
+ attrs_value = coerce_attrs(attrs)
19
+ kids = coerce_children(children)
20
+
21
+ new(
22
+ name: coerce_name(name),
23
+ attrs: attrs_value,
24
+ own_text: own_text.to_s.freeze,
25
+ children: kids,
26
+ tag_path: tag_path.to_s.freeze
27
+ )
28
+ end
29
+
30
+ def self.coerce_name(name)
31
+ raise ArgumentError, 'name is required' if name.nil? || name.to_s.strip.empty?
32
+
33
+ name.to_sym
34
+ end
35
+ private_class_method :coerce_name
36
+
37
+ def self.coerce_attrs(attrs)
38
+ attrs_value = attrs.nil? ? Attrs.empty : attrs
39
+ raise ArgumentError, 'attrs must be SST::Attrs' unless attrs_value.is_a?(Attrs)
40
+
41
+ attrs_value
42
+ end
43
+ private_class_method :coerce_attrs
44
+
45
+ def self.coerce_children(children)
46
+ kids = Array(children).freeze
47
+ raise ArgumentError, 'children must be SST::Node instances' unless kids.all?(Node)
48
+
49
+ kids
50
+ end
51
+ private_class_method :coerce_children
52
+
53
+ ##
54
+ # @return [Boolean]
55
+ def link?
56
+ name == :a && eligible_href?
57
+ end
58
+
59
+ ##
60
+ # @return [Boolean]
61
+ def image? = name == :img
62
+
63
+ ##
64
+ # @return [Boolean]
65
+ def heading? = Tags::HEADING_NAMES.include?(name)
66
+
67
+ ##
68
+ # @return [Boolean]
69
+ def utility_landmark? = Tags::UTILITY_LANDMARK_NAMES.include?(name)
70
+
71
+ ##
72
+ # @return [Boolean]
73
+ def ignored_container_name? = Tags::IGNORED_CONTAINER_NAMES.include?(name)
74
+
75
+ ##
76
+ # @return [Boolean]
77
+ def eligible_href?
78
+ href = attrs.href
79
+ return false if href.nil? || href.empty?
80
+
81
+ Tags::SKIP_HREF_PREFIXES.none? { |prefix| href.start_with?(prefix) }
82
+ end
83
+
84
+ ##
85
+ # Depth-first enumeration of self and descendants.
86
+ #
87
+ # @yieldparam node [Node]
88
+ # @return [Enumerator, Array<Node>]
89
+ def each_node(&block)
90
+ return enum_for(:each_node) unless block
91
+
92
+ yield self
93
+ children.each { |child| child.each_node(&block) }
94
+ end
95
+
96
+ ##
97
+ # @return [Array<Node>] all descendants excluding self
98
+ def descendants
99
+ children.flat_map { |child| [child, *child.descendants] }
100
+ end
101
+
102
+ ##
103
+ # @return [Array<Node>]
104
+ def find_all(&predicate)
105
+ each_node.select(&predicate)
106
+ end
107
+
108
+ ##
109
+ # @return [Node, nil]
110
+ def find(&predicate)
111
+ each_node.find(&predicate)
112
+ end
113
+
114
+ ##
115
+ # Visible text with block newlines, excluding optional subtrees.
116
+ #
117
+ # @param separator [String]
118
+ # @param exclude [Array<Node>, nil]
119
+ # @return [String, nil]
120
+ def visible_text(separator: ' ', exclude: nil)
121
+ if exclude.nil? && separator == ' ' && (index = Index.for_node(self))
122
+ return index.memo_visible_text(self)
123
+ end
124
+
125
+ Text.extract(self, separator:, exclude:)
126
+ end
127
+
128
+ ##
129
+ # @return [Integer] alphanumeric word count of visible text
130
+ def word_count
131
+ if (index = Index.for_node(self))
132
+ return index.memo_word_count(self)
133
+ end
134
+
135
+ visible_text.to_s.scan(/\p{Alnum}+/).size
136
+ end
137
+
138
+ ##
139
+ # @return [Float] words per descendant link
140
+ def text_density
141
+ words = word_count
142
+ links = descendants.count(&:link?)
143
+ links.zero? ? words.to_f : words.to_f / links
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module Html2rss
6
+ module SST
7
+ ##
8
+ # Sole Nokogiri consumer on the heuristic auto-source path. Builds an
9
+ # immutable SST::Document with parent/depth/chrome indices.
10
+ class Normalizer # rubocop:disable Metrics/ClassLength
11
+ # Hard ceiling for SST node allocations; beyond this we degrade.
12
+ MAX_NODES = 5_000
13
+
14
+ # Tags stripped entirely from the SST.
15
+ STRIPPED_TAGS = Set['script', 'style', 'noscript', 'iframe', 'svg', 'template'].freeze
16
+
17
+ # Kept when MAX_NODES forces semantic-tag-only degrade.
18
+ SEMANTIC_DEGRADE_TAGS = Set['html', 'body', 'article', 'section', 'li', 'tr', 'div', 'a',
19
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'time', 'img', 'p',
20
+ 'ul', 'ol', 'main'].freeze
21
+
22
+ # Attribute names preserved in Attrs#raw for extraction.
23
+ RAW_ATTR_KEEP = /
24
+ \A(
25
+ data- |
26
+ aria- |
27
+ class |
28
+ id |
29
+ href |
30
+ src |
31
+ srcset |
32
+ style |
33
+ datetime |
34
+ itemprop |
35
+ type |
36
+ category |
37
+ categories |
38
+ tag |
39
+ tags |
40
+ topic |
41
+ topics |
42
+ section |
43
+ sections |
44
+ label |
45
+ labels |
46
+ theme |
47
+ themes |
48
+ subject |
49
+ subjects
50
+ )
51
+ /xi
52
+
53
+ class << self
54
+ ##
55
+ # @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
56
+ # HTML string or already-parsed node (String is parsed once here)
57
+ # @return [Document]
58
+ # @raise [ArgumentError] when input is nil or unsupported
59
+ def call(input)
60
+ raise ArgumentError, 'input is required' if input.nil?
61
+
62
+ new(coerce(input)).call
63
+ end
64
+
65
+ private
66
+
67
+ # @param input [String, Nokogiri::XML::Node]
68
+ # @return [Nokogiri::XML::Node]
69
+ def coerce(input)
70
+ case input
71
+ when String then Nokogiri::HTML(input)
72
+ when Nokogiri::XML::Node then input
73
+ else
74
+ raise ArgumentError, "expected String or Nokogiri::XML::Node, got #{input.class}"
75
+ end
76
+ end
77
+ end
78
+
79
+ # @param parsed_body [Nokogiri::HTML::Document, Nokogiri::XML::Node]
80
+ def initialize(parsed_body)
81
+ @parsed_body = parsed_body
82
+ @node_count = 0
83
+ @degraded = false
84
+ @parents = {}.compare_by_identity
85
+ @depths = {}.compare_by_identity
86
+ @ignored_chrome = {}.compare_by_identity
87
+ end
88
+
89
+ ##
90
+ # @return [Document]
91
+ def call
92
+ root_nk = @parsed_body.respond_to?(:root) ? (@parsed_body.at_css('html') || @parsed_body.root) : @parsed_body
93
+ root = normalize_element(root_nk, parent: nil, depth: 0, path: '', chrome: false)
94
+ raise ArgumentError, 'SST Normalizer produced an empty tree' unless root
95
+
96
+ index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
97
+ Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
98
+ end
99
+
100
+ private
101
+
102
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
103
+ def normalize_element(nk_node, parent:, depth:, path:, chrome:)
104
+ return unless nk_node.respond_to?(:name)
105
+ return if nk_node.text? || nk_node.comment? || nk_node.cdata?
106
+
107
+ tag = nk_node.name.to_s.downcase
108
+ return if STRIPPED_TAGS.include?(tag)
109
+ return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
110
+
111
+ if @node_count >= MAX_NODES && !@degraded
112
+ @degraded = true
113
+ Html2rss::Log.warn("sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only")
114
+ end
115
+
116
+ return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
117
+
118
+ @node_count += 1
119
+ tag_path = path.empty? ? "/#{tag}" : "#{path}/#{tag}"
120
+ attrs = extract_attrs(nk_node)
121
+ own_text = direct_text(nk_node)
122
+ chrome_here = chrome || Tags::IGNORED_CONTAINER_NAMES.include?(tag.to_sym)
123
+
124
+ children = nk_node.children.filter_map do |child|
125
+ normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
126
+ end.freeze
127
+
128
+ node = Node.build(name: tag, attrs:, own_text:, children:, tag_path:)
129
+ @parents[node] = parent == :pending ? nil : parent
130
+ @depths[node] = depth
131
+ @ignored_chrome[node] = chrome_here
132
+
133
+ # Fix parent pointers now that node exists.
134
+ children.each { |child| @parents[child] = node }
135
+
136
+ node
137
+ end
138
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
139
+
140
+ def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
141
+ Attrs.build(
142
+ href: nk_node['href'],
143
+ src: nk_node['src'],
144
+ id: nk_node['id'],
145
+ class_names: nk_node['class'].to_s.split(/\s+/).reject(&:empty?),
146
+ datetime: nk_node['datetime'],
147
+ itemprop: nk_node['itemprop'],
148
+ style: nk_node['style'],
149
+ srcset: nk_node['srcset'],
150
+ type: nk_node['type'],
151
+ raw: raw_attrs(nk_node)
152
+ )
153
+ end
154
+
155
+ def raw_attrs(nk_node)
156
+ typed = %w[href src id class datetime itemprop style srcset type].to_set
157
+ nk_node.attribute_nodes.each_with_object({}) do |attr, raw|
158
+ name = attr.name.to_s
159
+ next if typed.include?(name)
160
+ next unless name.match?(RAW_ATTR_KEEP)
161
+
162
+ raw[name] = attr.value.to_s
163
+ end
164
+ end
165
+
166
+ def direct_text(nk_node)
167
+ nk_node.children.select(&:text?).map(&:text).join
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module SST
5
+ ##
6
+ # Shared tag-name and href constants for SST nodes.
7
+ module Tags
8
+ # Heading element names.
9
+ HEADING_NAMES = %i[h1 h2 h3 h4 h5 h6].to_set.freeze
10
+ # Block-level names that contribute structural newlines in text.
11
+ BLOCK_NAMES = %i[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
12
+ # Names skipped when collecting visible text.
13
+ INVISIBLE_NAMES = %i[svg script noscript style template].to_set.freeze
14
+ # Landmark names treated as utility chrome.
15
+ UTILITY_LANDMARK_NAMES = %i[nav aside footer menu].to_set.freeze
16
+ # Container names ignored as article roots.
17
+ IGNORED_CONTAINER_NAMES = %i[nav footer header svg script style].to_set.freeze
18
+ # Names excluded from class/structure clustering.
19
+ CLUSTER_EXCLUDED_NAMES = %i[html body nav footer header svg script style].to_set.freeze
20
+ # Href prefixes that never count as content destinations.
21
+ SKIP_HREF_PREFIXES = ['#', 'javascript:', 'mailto:', 'tel:', 'file://', 'sms:', 'data:'].freeze
22
+ # Layout-ish names used when resolving nested wrapper groups.
23
+ LAYOUT_NAMES = %i[div section article li ul ol].to_set.freeze
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module SST
5
+ ##
6
+ # Visible-text extraction for SST nodes (port of Navigator::TextExtractor).
7
+ module Text
8
+ module_function
9
+
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
22
+ end
23
+
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
31
+
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)
37
+
38
+ text, block = child_text_and_block(child, separator, excluded)
39
+ next if text.empty?
40
+
41
+ append_separator!(parts, separator, block, last_block)
42
+ parts << text
43
+ last_block = block
44
+ end
45
+ end
46
+ module_function :collect_parts
47
+ private_class_method :collect_parts
48
+
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
60
+
61
+ def append_separator!(parts, separator, is_block, last_was_block)
62
+ return if parts.empty?
63
+
64
+ parts << if is_block || last_was_block
65
+ (separator == ' ' ? "\n" : separator)
66
+ else
67
+ ' '
68
+ end
69
+ end
70
+ module_function :append_separator!
71
+ private_class_method :append_separator!
72
+
73
+ def visible_child?(node)
74
+ !Tags::INVISIBLE_NAMES.include?(node.name) &&
75
+ !(node.name == :a && node.attrs.href&.start_with?('#'))
76
+ end
77
+ module_function :visible_child?
78
+ private_class_method :visible_child?
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Simplified Semantic Tree namespace for the heuristic auto-source pipeline.
6
+ module SST
7
+ end
8
+ 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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.23.0'
5
+ VERSION = '0.25.0'
6
6
  public_constant :VERSION
7
7
  end