html2rss 0.24.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  3. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  4. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  5. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  7. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  8. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  9. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  10. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  11. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  12. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  13. data/lib/html2rss/auto_source/scraper.rb +81 -44
  14. data/lib/html2rss/auto_source/segment.rb +29 -0
  15. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  16. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  17. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  18. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  19. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  20. data/lib/html2rss/auto_source.rb +74 -21
  21. data/lib/html2rss/cli.rb +8 -13
  22. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  23. data/lib/html2rss/config/request_controls.rb +33 -0
  24. data/lib/html2rss/config/validator.rb +15 -8
  25. data/lib/html2rss/config.rb +6 -2
  26. data/lib/html2rss/feed_builder/rss.rb +17 -5
  27. data/lib/html2rss/feed_pipeline.rb +4 -0
  28. data/lib/html2rss/feed_result.rb +1 -1
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  31. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  32. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  33. data/lib/html2rss/html/article_rules/category.rb +55 -0
  34. data/lib/html2rss/html/article_rules/date.rb +25 -0
  35. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  36. data/lib/html2rss/html/article_rules/image.rb +109 -0
  37. data/lib/html2rss/html/article_rules.rb +10 -0
  38. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  39. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  40. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  41. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  42. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  43. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  44. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  45. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  46. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  47. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  48. data/lib/html2rss/link_destination.rb +8 -0
  49. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  50. data/lib/html2rss/request_service/network_guard.rb +5 -3
  51. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  52. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  53. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  54. data/lib/html2rss/scoring/engine.rb +101 -0
  55. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  56. data/lib/html2rss/scoring/observation.rb +53 -0
  57. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  58. data/lib/html2rss/scoring/score.rb +30 -0
  59. data/lib/html2rss/scoring.rb +33 -0
  60. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  61. data/lib/html2rss/selectors.rb +0 -20
  62. data/lib/html2rss/sst/attrs.rb +91 -0
  63. data/lib/html2rss/sst/document.rb +23 -0
  64. data/lib/html2rss/sst/index.rb +112 -0
  65. data/lib/html2rss/sst/node.rb +147 -0
  66. data/lib/html2rss/sst/normalizer.rb +171 -0
  67. data/lib/html2rss/sst/tags.rb +26 -0
  68. data/lib/html2rss/sst/text.rb +81 -0
  69. data/lib/html2rss/sst.rb +8 -0
  70. data/lib/html2rss/version.rb +1 -1
  71. data/lib/html2rss.rb +26 -26
  72. data/schema/html2rss-config.schema.json +20 -0
  73. metadata +42 -18
  74. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  75. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  76. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  77. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  78. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  79. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  80. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  81. data/lib/html2rss/auto_source/discovery.rb +0 -14
  82. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  83. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  84. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  85. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  86. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  87. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  88. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  89. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Resolves href strings into memoized LinkDestination::DestinationFacts for one page base URL.
7
+ class LinkResolver
8
+ # Captures the href portion before a fragment for memoization keys.
9
+ HREF_BASE_PATTERN = /\A([^#]*)/
10
+
11
+ # @param base_url [String, Html2rss::Url]
12
+ def initialize(base_url)
13
+ @base_url = base_url
14
+ @by_href = {}
15
+ @by_node = {}.compare_by_identity
16
+ @text_classifier = LinkDestination::TextClassifier.new
17
+ end
18
+
19
+ # @return [LinkDestination::TextClassifier]
20
+ attr_reader :text_classifier
21
+
22
+ ##
23
+ # @param node_or_href [SST::Node, String, #to_s]
24
+ # @return [LinkDestination::DestinationFacts, nil]
25
+ def destination_facts(node_or_href)
26
+ return @by_node[node_or_href] if node_or_href.is_a?(SST::Node) && @by_node.key?(node_or_href)
27
+
28
+ href = extract_href(node_or_href)
29
+ return unless href
30
+
31
+ facts = (@by_href[href] ||= build_facts(href))
32
+ @by_node[node_or_href] = facts if node_or_href.is_a?(SST::Node)
33
+ facts
34
+ rescue ArgumentError
35
+ nil
36
+ end
37
+
38
+ ##
39
+ # @param text [String, #to_s]
40
+ # @return [Boolean]
41
+ def utility_text?(text) = @text_classifier.utility?(text)
42
+
43
+ ##
44
+ # @param text [String, #to_s]
45
+ # @return [Boolean]
46
+ def utility_prefix_text?(text) = @text_classifier.utility_prefix?(text)
47
+
48
+ ##
49
+ # @param text [String, #to_s]
50
+ # @return [Boolean]
51
+ def recommended_text?(text) = @text_classifier.recommended?(text)
52
+
53
+ private
54
+
55
+ def extract_href(node_or_href)
56
+ raw = case node_or_href
57
+ when SST::Node then node_or_href.attrs.href
58
+ else node_or_href
59
+ end
60
+ return unless raw
61
+
62
+ base = raw.to_s[HREF_BASE_PATTERN, 1].to_s.strip
63
+ base unless base.empty?
64
+ end
65
+
66
+ def build_facts(href)
67
+ url = Html2rss::Url.from_relative(href, @base_url)
68
+ LinkDestination::DestinationFacts.build(url)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Typed container signals produced by {ContainerAssessor} for {Engine} rules.
7
+ Observation = Data.define(
8
+ :title_word_count,
9
+ :path_length,
10
+ :content_path,
11
+ :publish_marker,
12
+ :descriptive_context,
13
+ :article_container,
14
+ :content_tokens,
15
+ :junk_tokens,
16
+ :utility_prefix_title,
17
+ :recommended_title,
18
+ :utility_path,
19
+ :strong_post_suffix,
20
+ :shallow,
21
+ :high_confidence_junk_path,
22
+ :high_confidence_utility_destination,
23
+ :selected_anchor_present
24
+ ) do
25
+ ##
26
+ # @return [Boolean] true when fewer than two positive content signals are present
27
+ def weak_signals?
28
+ [article_container, publish_marker, descriptive_context, content_path].count(&:itself) < 2
29
+ end
30
+
31
+ ##
32
+ # @param mode [Symbol] +:strict+ for rank/rank_top; +:lenient+ for select_eligible
33
+ # @return [Boolean] true when the observation should be dropped before ranking
34
+ def hard_junk?(mode: :strict)
35
+ case mode
36
+ when :strict
37
+ high_confidence_junk_path || chrome_title_junk?
38
+ when :lenient
39
+ (high_confidence_junk_path && weak_signals?) || chrome_title_junk?
40
+ else
41
+ raise ArgumentError, "unknown hard_junk mode: #{mode.inspect}"
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def chrome_title_junk? # rubocop:disable Metrics/CyclomaticComplexity
48
+ (selected_anchor_present && recommended_title && shallow && weak_signals?) ||
49
+ (selected_anchor_present && utility_prefix_title && high_confidence_utility_destination && weak_signals?)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # A segment paired with its composite score.
7
+ RankedSegment = Data.define(:segment, :score) do
8
+ ##
9
+ # @param segment [Html2rss::AutoSource::Segment]
10
+ # @param score [Score]
11
+ # @return [RankedSegment]
12
+ # @raise [ArgumentError] on invalid types
13
+ def self.build(segment:, score:)
14
+ raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
15
+ raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
16
+
17
+ new(segment:, score:)
18
+ end
19
+
20
+ ##
21
+ # @return [SST::Node]
22
+ def root_node = segment.root_node
23
+
24
+ ##
25
+ # @return [SST::Node, nil]
26
+ def primary_link = segment.primary_link
27
+
28
+ ##
29
+ # @return [Float]
30
+ def final_score = score.composite
31
+
32
+ ##
33
+ # @return [Float]
34
+ def quality_score = score.quality
35
+
36
+ ##
37
+ # @return [Float]
38
+ def junk_score = score.junk
39
+
40
+ ##
41
+ # @return [Integer]
42
+ def position = segment.position
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Composite score with quality/junk split and optional typed feature breakdown.
7
+ Score = Data.define(:composite, :quality, :junk, :breakdown) do
8
+ ##
9
+ # @param composite [Numeric]
10
+ # @param quality [Numeric, nil]
11
+ # @param junk [Numeric, nil]
12
+ # @param breakdown [Hash{Symbol => Numeric}, nil]
13
+ # @return [Score]
14
+ # @raise [ArgumentError] when composite is not numeric
15
+ def self.build(composite:, quality: nil, junk: nil, breakdown: nil)
16
+ raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
17
+
18
+ parts = breakdown.nil? ? Score::EMPTY_BREAKDOWN : breakdown.transform_keys { FeatureId.assert!(_1) }.freeze
19
+ new(
20
+ composite: composite.to_f,
21
+ quality: (quality.nil? ? composite : quality).to_f,
22
+ junk: (junk || 0).to_f,
23
+ breakdown: parts
24
+ )
25
+ end
26
+ end
27
+ # Shared empty feature breakdown for scores without per-feature tallies.
28
+ Score::EMPTY_BREAKDOWN = {}.freeze
29
+ end
30
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Declarative feature scoring for AutoSource SST segments.
6
+ module Scoring
7
+ ##
8
+ # Closed set of feature identifiers used by the scoring registry.
9
+ module FeatureId
10
+ # Closed set of allowed feature identifier symbols.
11
+ IDS = %i[
12
+ title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
13
+ publish_marker descriptive_context article_container content_tokens
14
+ non_content_utility_path utility_prefix_title_short shallow weak_container
15
+ recommended_title_non_content high_confidence_junk_path junk_tokens
16
+ heading_anchor heading_text_match meaningful_text content_like_destination
17
+ cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
18
+ ].to_set.freeze
19
+
20
+ module_function
21
+
22
+ ##
23
+ # @param id [Symbol]
24
+ # @return [Symbol]
25
+ # @raise [ArgumentError] when id is not in the closed set
26
+ def assert!(id)
27
+ raise ArgumentError, "unknown FeatureId: #{id.inspect}" unless IDS.include?(id)
28
+
29
+ id
30
+ end
31
+ end
32
+ end
33
+ end
@@ -92,12 +92,19 @@ module Html2rss
92
92
  # @param html [String]
93
93
  # @param url [String, Html2rss::Url]
94
94
  # @return [String, nil]
95
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
95
96
  def self.get(html, url)
96
97
  return nil if String(html).empty?
97
98
 
99
+ @fragment_cache ||= {}
100
+ key = [html, url.to_s]
101
+ return @fragment_cache[key] if @fragment_cache.key?(key)
102
+
103
+ @fragment_cache.clear if @fragment_cache.size > 256
98
104
  context = Selectors::Context.new(config: { channel: { url: } }, options: {})
99
- new(html, context).get
105
+ @fragment_cache[key] = new(html, context).get
100
106
  end
107
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
101
108
 
102
109
  ##
103
110
  # @param channel_url [String, Html2rss::Url]
@@ -117,6 +124,8 @@ module Html2rss
117
124
  ->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
118
125
  ]
119
126
  )
127
+ config[:elements].delete('style')
128
+ config.delete(:css)
120
129
  config[:elements].push('audio', 'video', 'source')
121
130
  config.freeze
122
131
  end
@@ -48,8 +48,6 @@ module Html2rss
48
48
  @url = response.url
49
49
  @selectors = selectors
50
50
  @time_zone = time_zone
51
-
52
- prepare_selectors!
53
51
  @rss_item_attributes = @selectors.keys.select { |key| SELECTABLE_SELECTOR_KEYS.include?(key) }
54
52
  end
55
53
 
@@ -180,24 +178,6 @@ module Html2rss
180
178
  )
181
179
  end
182
180
 
183
- def prepare_selectors!
184
- validate_url_and_link_exclusivity!
185
- fix_url_and_link!
186
- end
187
-
188
- def validate_url_and_link_exclusivity!
189
- return unless @selectors.key?(:url) && @selectors.key?(:link)
190
-
191
- raise InvalidSelectorName, 'You must either use "url" or "link" your selectors. Using both is not supported.'
192
- end
193
-
194
- def fix_url_and_link!
195
- return if @selectors[:url] || !@selectors.key?(:link)
196
-
197
- @selectors = @selectors.dup
198
- @selectors[:url] = @selectors[:link]
199
- end
200
-
201
181
  def parsed_body
202
182
  parsed_body_for(response)
203
183
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module SST
5
+ ##
6
+ # Shared empty defaults for {Attrs} (kept outside Data.define for constant visibility).
7
+ module AttrDefaults
8
+ # Shared empty class_names array for Attrs defaults.
9
+ EMPTY_CLASS_NAMES = [].freeze
10
+ # Shared empty raw attribute hash for Attrs defaults.
11
+ EMPTY_RAW = {}.freeze
12
+ end
13
+
14
+ ##
15
+ # Typed HTML attributes for an SST node. Absent fields are +nil+.
16
+ Attrs = Data.define(
17
+ :href, :src, :id, :class_names, :datetime, :itemprop, :style, :srcset, :type, :raw
18
+ ) do
19
+ class << self
20
+ ##
21
+ # @param href [String, nil]
22
+ # @param src [String, nil]
23
+ # @param id [String, nil]
24
+ # @param class_names [Array<String>, nil]
25
+ # @param datetime [String, nil]
26
+ # @param itemprop [String, nil]
27
+ # @param style [String, nil]
28
+ # @param srcset [String, nil]
29
+ # @param type [String, nil]
30
+ # @param raw [Hash{String => String}, nil]
31
+ # @return [Attrs]
32
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
33
+ def build(href: nil, src: nil, id: nil, class_names: nil, datetime: nil, itemprop: nil,
34
+ style: nil, srcset: nil, type: nil, raw: nil)
35
+ new(
36
+ href: blank_to_nil(href),
37
+ src: blank_to_nil(src),
38
+ id: blank_to_nil(id),
39
+ class_names: normalize_class_names(class_names),
40
+ datetime: blank_to_nil(datetime),
41
+ itemprop: blank_to_nil(itemprop),
42
+ style: blank_to_nil(style),
43
+ srcset: blank_to_nil(srcset),
44
+ type: blank_to_nil(type),
45
+ raw: normalize_raw(raw)
46
+ )
47
+ end
48
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
49
+
50
+ ##
51
+ # @return [Attrs]
52
+ def empty = EMPTY_ATTRS
53
+
54
+ private
55
+
56
+ def blank_to_nil(value)
57
+ str = value&.to_s
58
+ return if str.nil? || str.strip.empty?
59
+
60
+ str.strip.freeze
61
+ end
62
+
63
+ def normalize_class_names(class_names)
64
+ case class_names
65
+ when nil then AttrDefaults::EMPTY_CLASS_NAMES
66
+ when Array then class_names.map { |token| token.to_s.freeze }.freeze
67
+ else raise ArgumentError, "class_names must be an Array, got #{class_names.class}"
68
+ end
69
+ end
70
+
71
+ def normalize_raw(raw)
72
+ case raw
73
+ when nil then AttrDefaults::EMPTY_RAW
74
+ when Hash then raw.transform_values { |v| v.to_s.freeze }.freeze
75
+ else raise ArgumentError, "raw must be a Hash, got #{raw.class}"
76
+ end
77
+ end
78
+ end
79
+
80
+ ##
81
+ # @return [String]
82
+ def class_attr = class_names.join(' ')
83
+ end
84
+
85
+ # Shared empty Attrs instance for nodes without attributes.
86
+ EMPTY_ATTRS = Attrs.new(
87
+ href: nil, src: nil, id: nil, class_names: AttrDefaults::EMPTY_CLASS_NAMES, datetime: nil,
88
+ itemprop: nil, style: nil, srcset: nil, type: nil, raw: AttrDefaults::EMPTY_RAW
89
+ ).freeze
90
+ end
91
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module SST
5
+ ##
6
+ # Immutable SST document: root node plus relationship index.
7
+ Document = Data.define(:root, :index, :degraded, :node_count) do
8
+ ##
9
+ # @param root [Node]
10
+ # @param index [Index]
11
+ # @param degraded [Boolean] true when MAX_NODES forced semantic-tag-only mode
12
+ # @param node_count [Integer]
13
+ # @return [Document]
14
+ # @raise [ArgumentError] when root/index missing
15
+ def self.build(root:, index:, degraded: false, node_count: 0)
16
+ raise ArgumentError, 'root is required' unless root.is_a?(Node)
17
+ raise ArgumentError, 'index is required' unless index.is_a?(Index)
18
+
19
+ new(root:, index:, degraded: !!degraded, node_count: Integer(node_count))
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module SST
5
+ ##
6
+ # Parent/depth indices for an SST tree. Built once by the Normalizer so
7
+ # Scoring/Segmenter never need Nokogiri ancestor walks.
8
+ class Index
9
+ # Weak node→index bindings so +Node#visible_text+ can memoize without call-site churn.
10
+ NODE_BINDINGS = ObjectSpace::WeakMap.new
11
+
12
+ ##
13
+ # @param node [Node]
14
+ # @return [Index, nil]
15
+ def self.for_node(node) = NODE_BINDINGS[node]
16
+
17
+ ##
18
+ # @param root [Node]
19
+ # @param parents [Hash{Node => Node, nil}]
20
+ # @param depths [Hash{Node => Integer}]
21
+ # @param ignored_chrome [Hash{Node => Boolean}]
22
+ def initialize(root:, parents:, depths:, ignored_chrome:)
23
+ @root = root
24
+ @parents = parents
25
+ @depths = depths
26
+ @ignored_chrome = ignored_chrome
27
+ @visible_text = {}.compare_by_identity
28
+ @word_count = {}.compare_by_identity
29
+ bind_tree(root)
30
+ end
31
+
32
+ attr_reader :root
33
+
34
+ ##
35
+ # @param node [Node]
36
+ # @return [Node, nil]
37
+ def parent_of(node) = @parents[node]
38
+
39
+ ##
40
+ # @param node [Node]
41
+ # @return [Integer]
42
+ def depth_of(node) = @depths.fetch(node, 0)
43
+
44
+ ##
45
+ # @param node [Node]
46
+ # @return [Boolean]
47
+ def ignored_chrome?(node) = @ignored_chrome.fetch(node, false)
48
+
49
+ ##
50
+ # Memoized default visible text (separator space, no excludes).
51
+ #
52
+ # @param node [Node]
53
+ # @return [String, nil]
54
+ def memo_visible_text(node)
55
+ return @visible_text[node] if @visible_text.key?(node)
56
+
57
+ @visible_text[node] = Text.extract(node)
58
+ end
59
+
60
+ ##
61
+ # @param node [Node]
62
+ # @return [Integer]
63
+ def memo_word_count(node)
64
+ return @word_count[node] if @word_count.key?(node)
65
+
66
+ @word_count[node] = memo_visible_text(node).to_s.scan(/\p{Alnum}+/).size
67
+ end
68
+
69
+ ##
70
+ # @param child [Node]
71
+ # @param ancestor [Node]
72
+ # @return [Boolean]
73
+ def descendant_of?(child, ancestor)
74
+ curr = parent_of(child)
75
+ while curr
76
+ return true if curr.equal?(ancestor)
77
+
78
+ curr = parent_of(curr)
79
+ end
80
+ false
81
+ end
82
+
83
+ ##
84
+ # @param node [Node]
85
+ # @param condition [#call]
86
+ # @return [Node, nil]
87
+ def parent_until(node, condition)
88
+ curr = node
89
+ while curr && curr.name != :html
90
+ return curr if condition.call(curr)
91
+
92
+ curr = parent_of(curr)
93
+ end
94
+ nil
95
+ end
96
+
97
+ ##
98
+ # @yieldparam node [Node]
99
+ # @return [Enumerator]
100
+ def each_node(&)
101
+ root.each_node(&)
102
+ end
103
+
104
+ private
105
+
106
+ def bind_tree(node)
107
+ NODE_BINDINGS[node] = self
108
+ node.children.each { bind_tree(_1) }
109
+ end
110
+ end
111
+ end
112
+ end
@@ -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