html2rss 0.24.0 → 0.26.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -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 +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -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/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
@@ -38,6 +38,15 @@ module Html2rss
38
38
  # Would return:
39
39
  # '<p>Lorem <b>ipsum</b> dolor ...</p>'
40
40
  class SanitizeHtml < Base
41
+ # JSON Schema description exported via +schema_doc+.
42
+ DESCRIPTION = 'Sanitize HTML (sanitize gem RELAXED plus html2rss defaults: absolute URLs, ' \
43
+ 'safe link/img attributes, wrap lone images in anchors).'
44
+
45
+ # Example post-process objects for JSON Schema +examples+.
46
+ EXAMPLES = [
47
+ { 'name' => 'sanitize_html' }
48
+ ].freeze
49
+
41
50
  # @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
42
51
  TAG_ATTRIBUTES = {
43
52
  'a' => {
@@ -81,6 +90,10 @@ module Html2rss
81
90
  'preload' => 'none'
82
91
  }
83
92
  }.freeze
93
+
94
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
95
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :sanitize_html, klass: self)
96
+
84
97
  # @param value [String] extracted selector value
85
98
  # @param context [Selectors::Context] post-processor context
86
99
  # @return [void]
@@ -92,12 +105,19 @@ module Html2rss
92
105
  # @param html [String]
93
106
  # @param url [String, Html2rss::Url]
94
107
  # @return [String, nil]
108
+ # rubocop:disable ThreadSafety/ClassInstanceVariable
95
109
  def self.get(html, url)
96
110
  return nil if String(html).empty?
97
111
 
112
+ @fragment_cache ||= {}
113
+ key = [html, url.to_s]
114
+ return @fragment_cache[key] if @fragment_cache.key?(key)
115
+
116
+ @fragment_cache.clear if @fragment_cache.size > 256
98
117
  context = Selectors::Context.new(config: { channel: { url: } }, options: {})
99
- new(html, context).get
118
+ @fragment_cache[key] = new(html, context).get
100
119
  end
120
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
101
121
 
102
122
  ##
103
123
  # @param channel_url [String, Html2rss::Url]
@@ -117,6 +137,8 @@ module Html2rss
117
137
  ->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
118
138
  ]
119
139
  )
140
+ config[:elements].delete('style')
141
+ config.delete(:css)
120
142
  config[:elements].push('audio', 'video', 'source')
121
143
  config.freeze
122
144
  end
@@ -30,6 +30,27 @@ module Html2rss
30
30
  # Would return:
31
31
  # 'bar'
32
32
  class Substring < Base
33
+ # Required config field types (validator introspection via +Options+).
34
+ OPTION_TYPES = { start: Integer }.freeze
35
+
36
+ # Optional config field types (validated when the key is present and non-nil).
37
+ OPTIONAL_OPTION_TYPES = { end: Integer }.freeze
38
+
39
+ # Config fields required by this post-processor (validator / schema introspection).
40
+ Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
41
+
42
+ # JSON Schema description exported via +schema_doc+.
43
+ DESCRIPTION = 'Return a slice of the extracted string using Integer `start` and optional `end` ' \
44
+ '(Ruby String#[] range semantics; end may be omitted).'
45
+
46
+ # Example post-process objects for JSON Schema +examples+.
47
+ EXAMPLES = [
48
+ { 'name' => 'substring', 'start' => 4, 'end' => 6 }
49
+ ].freeze
50
+
51
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
52
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :substring, klass: self)
53
+
33
54
  # @param value [String] extracted selector value
34
55
  # @param context [Selectors::Context] post-processor context
35
56
  # @return [void]
@@ -34,6 +34,26 @@ module Html2rss
34
34
  # Would return:
35
35
  # 'Product (23,42€)'
36
36
  class Template < Base
37
+ # Required config field types (validator introspection via +Options+).
38
+ OPTION_TYPES = { string: String }.freeze
39
+
40
+ # Config fields required by this post-processor (validator / schema introspection).
41
+ Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
42
+
43
+ # JSON Schema description exported via +schema_doc+.
44
+ # rubocop:disable Style/FormatStringToken -- documents Kernel#format `%{key}` placeholders
45
+ DESCRIPTION = 'Format a string with Kernel#format-style placeholders (`%{key}` / `%<key>s`). ' \
46
+ '`%{self}` is the current selector value; other keys resolve sibling selectors.'
47
+
48
+ # Example post-process objects for JSON Schema +examples+.
49
+ EXAMPLES = [
50
+ { 'name' => 'template', 'string' => '`%{self}` (`%{price}`)' }
51
+ ].freeze
52
+ # rubocop:enable Style/FormatStringToken
53
+
54
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
55
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :template, klass: self)
56
+
37
57
  # @param value [String] extracted selector value
38
58
  # @param context [Selectors::Context] post-processor context
39
59
  # @return [void]
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Selectors
5
+ ##
6
+ # Builds JSON Schema fragments from extractor / post-processor class constants.
7
+ #
8
+ # Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTION_TYPES+;
9
+ # this module is the export adapter only.
10
+ module SchemaDoc
11
+ # Maps Ruby option types to JSON Schema +type+ strings.
12
+ RUBY_TO_JSON_TYPE = {
13
+ String => 'string',
14
+ Integer => 'integer',
15
+ Hash => 'object',
16
+ Array => 'array'
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ ##
22
+ # @param name [Symbol, String] registry key
23
+ # @param klass [Class] post-processor class
24
+ # @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
25
+ def for_post_processor(name:, klass:)
26
+ name = name.to_s
27
+ {
28
+ type: 'object',
29
+ title: name,
30
+ description: klass::DESCRIPTION,
31
+ examples: klass::EXAMPLES,
32
+ properties: post_processor_properties(name, klass),
33
+ required: post_processor_required(klass),
34
+ additionalProperties: true
35
+ }
36
+ end
37
+
38
+ ##
39
+ # @param name [Symbol, String] registry key
40
+ # @param klass [Class] extractor class
41
+ # @return [Hash{Symbol => Object}] JSON Schema for one extractor name
42
+ def for_extractor(name:, klass:)
43
+ name = name.to_s
44
+ {
45
+ type: 'string',
46
+ const: name,
47
+ title: name,
48
+ description: klass::DESCRIPTION,
49
+ examples: klass::EXAMPLES
50
+ }
51
+ end
52
+
53
+ ##
54
+ # @param name [String] registry key
55
+ # @param klass [Class]
56
+ # @return [Hash{Symbol => Hash}]
57
+ def post_processor_properties(name, klass)
58
+ properties = { name: { type: 'string', const: name } }
59
+ option_types_for(klass).each do |field, ruby_type|
60
+ properties[field] = { type: json_type_for(ruby_type) }
61
+ end
62
+ properties
63
+ end
64
+ module_function :post_processor_properties
65
+
66
+ ##
67
+ # @param klass [Class]
68
+ # @return [Array<String>]
69
+ def post_processor_required(klass)
70
+ required = ['name']
71
+ return required unless klass.const_defined?(:OPTION_TYPES)
72
+
73
+ required + klass::OPTION_TYPES.keys.map(&:to_s)
74
+ end
75
+ module_function :post_processor_required
76
+
77
+ ##
78
+ # @param klass [Class]
79
+ # @return [Hash{Symbol => Class}]
80
+ def option_types_for(klass)
81
+ types = {}
82
+ types.merge!(klass::OPTION_TYPES) if klass.const_defined?(:OPTION_TYPES)
83
+ types.merge!(klass::OPTIONAL_OPTION_TYPES) if klass.const_defined?(:OPTIONAL_OPTION_TYPES)
84
+ types
85
+ end
86
+ module_function :option_types_for
87
+
88
+ ##
89
+ # @param ruby_type [Class]
90
+ # @return [String]
91
+ def json_type_for(ruby_type)
92
+ RUBY_TO_JSON_TYPE.fetch(ruby_type) do
93
+ raise ArgumentError, "unsupported OPTION_TYPES mapping for #{ruby_type}"
94
+ end
95
+ end
96
+ module_function :json_type_for
97
+ end
98
+ end
99
+ 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