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,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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.24.0'
5
+ VERSION = '0.25.0'
6
6
  public_constant :VERSION
7
7
  end
data/lib/html2rss.rb CHANGED
@@ -3,7 +3,10 @@
3
3
  require 'zeitwerk'
4
4
 
5
5
  loader = Zeitwerk::Loader.for_gem
6
- loader.inflector.inflect('cli' => 'CLI')
6
+ loader.inflector.inflect(
7
+ 'cli' => 'CLI',
8
+ 'sst' => 'SST'
9
+ )
7
10
  loader.setup
8
11
 
9
12
  require 'logger'
@@ -67,7 +70,10 @@ module Html2rss
67
70
  # rubocop:disable Metrics/ParameterLists
68
71
 
69
72
  ##
70
- # Scrapes the provided URL and returns an RSS object.
73
+ # Scrapes the provided URL without hand-written selectors and returns an RSS object.
74
+ #
75
+ # Builds an auto_source config, then FeedPipeline runs structured scrapers and
76
+ # (when needed) the SST heuristic path — see docs/auto_source.md.
71
77
  #
72
78
  # @param url [String] source page URL
73
79
  # @param strategy [Symbol] request strategy to use
@@ -75,18 +81,23 @@ module Html2rss
75
81
  # @param max_redirects [Integer, nil] optional redirect limit override
76
82
  # @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
77
83
  # @param local_file_path [String, nil] optional local HTML file path
84
+ # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
78
85
  # @return [RSS::Rss] generated RSS feed
79
86
  def self.auto_source(url,
80
87
  strategy: :auto,
81
88
  items_selector: nil,
82
89
  max_redirects: nil,
83
90
  max_requests: 4,
84
- local_file_path: nil)
85
- feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:, local_file_path:))
91
+ local_file_path: nil,
92
+ limit: nil)
93
+ feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
94
+ local_file_path:, limit:))
86
95
  end
87
96
 
88
97
  ##
89
- # Scrapes the provided URL and returns a JSONFeed 1.1 hash.
98
+ # Scrapes the provided URL without hand-written selectors and returns a JSONFeed 1.1 hash.
99
+ #
100
+ # Same auto_source pipeline as {.auto_source}; see docs/auto_source.md.
90
101
  #
91
102
  # @param url [String] source page URL
92
103
  # @param strategy [Symbol] request strategy to use
@@ -94,15 +105,17 @@ module Html2rss
94
105
  # @param max_redirects [Integer, nil] optional redirect limit override
95
106
  # @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
96
107
  # @param local_file_path [String, nil] optional local HTML file path
108
+ # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
97
109
  # @return [Hash] JSONFeed-compliant hash
98
110
  def self.auto_json_feed(url,
99
111
  strategy: :auto,
100
112
  items_selector: nil,
101
113
  max_redirects: nil,
102
114
  max_requests: 4,
103
- local_file_path: nil)
115
+ local_file_path: nil,
116
+ limit: nil)
104
117
  json_feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
105
- local_file_path:))
118
+ local_file_path:, limit:))
106
119
  end
107
120
 
108
121
  # rubocop:enable Metrics/ParameterLists
@@ -154,11 +167,14 @@ module Html2rss
154
167
  class << self
155
168
  private
156
169
 
157
- def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:, local_file_path: nil) # rubocop:disable Metrics/ParameterLists
170
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
171
+ def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
172
+ local_file_path: nil, limit: nil)
158
173
  config = Config.auto_source_config(
159
174
  url:,
160
175
  items_selector:,
161
- request_controls: shortcut_request_controls(strategy:, max_redirects:, max_requests:)
176
+ request_controls: Config::RequestControls.from_shortcut(strategy:, max_redirects:, max_requests:),
177
+ limit:
162
178
  )
163
179
  if local_file_path
164
180
  config[:request] ||= {}
@@ -166,23 +182,7 @@ module Html2rss
166
182
  end
167
183
  config
168
184
  end
169
-
170
- def shortcut_request_controls(strategy:, max_redirects:, max_requests:)
171
- Config::RequestControls.new(
172
- strategy:,
173
- max_redirects:,
174
- max_requests:,
175
- explicit_keys: explicit_request_control_keys(strategy:, max_redirects:, max_requests:)
176
- )
177
- end
178
-
179
- def explicit_request_control_keys(strategy:, max_redirects:, max_requests:)
180
- keys = []
181
- keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
182
- keys << :max_redirects unless max_redirects.nil?
183
- keys << :max_requests unless max_requests.nil?
184
- keys
185
- end
185
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
186
186
  end
187
187
 
188
188
  logger.level = defaults.log_level if logger.respond_to?(:level=)
@@ -45,6 +45,18 @@
45
45
  "null",
46
46
  "string"
47
47
  ]
48
+ },
49
+ "author": {
50
+ "type": [
51
+ "null",
52
+ "string"
53
+ ]
54
+ },
55
+ "image": {
56
+ "type": [
57
+ "null",
58
+ "string"
59
+ ]
48
60
  }
49
61
  },
50
62
  "required": [
@@ -94,6 +106,13 @@
94
106
  "$schema": "http://json-schema.org/draft-06/schema#",
95
107
  "type": "object",
96
108
  "properties": {
109
+ "limit": {
110
+ "type": "integer",
111
+ "not": {
112
+ "type": "null"
113
+ },
114
+ "exclusiveMinimum": 0
115
+ },
97
116
  "scraper": {
98
117
  "type": "object",
99
118
  "properties": {
@@ -269,6 +288,7 @@
269
288
  },
270
289
  "required": [],
271
290
  "default": {
291
+ "limit": 25,
272
292
  "scraper": {
273
293
  "wordpress_api": {
274
294
  "enabled": true
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html2rss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gil Desmarais
@@ -278,25 +278,13 @@ files:
278
278
  - lib/html2rss/article/enclosure.rb
279
279
  - lib/html2rss/auto_source.rb
280
280
  - lib/html2rss/auto_source/cleanup.rb
281
- - lib/html2rss/auto_source/discovery.rb
282
- - lib/html2rss/auto_source/discovery/dom_clustering.rb
283
- - lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb
284
- - lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb
285
- - lib/html2rss/auto_source/discovery/list_candidates.rb
286
- - lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb
287
- - lib/html2rss/auto_source/discovery/semantic_containers.rb
288
- - lib/html2rss/auto_source/discovery/sitemap.rb
289
- - lib/html2rss/auto_source/link_heuristics.rb
290
- - lib/html2rss/auto_source/link_heuristics/anchor_signals.rb
291
- - lib/html2rss/auto_source/link_heuristics/container_assessor.rb
292
- - lib/html2rss/auto_source/link_heuristics/container_signals.rb
293
- - lib/html2rss/auto_source/link_heuristics/destination_facts.rb
294
- - lib/html2rss/auto_source/link_heuristics/href_extractor.rb
295
- - lib/html2rss/auto_source/link_heuristics/path_classifier.rb
296
- - lib/html2rss/auto_source/link_heuristics/text_classifier.rb
297
281
  - lib/html2rss/auto_source/scraper.rb
298
282
  - lib/html2rss/auto_source/scraper/html.rb
299
283
  - lib/html2rss/auto_source/scraper/json_state.rb
284
+ - lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb
285
+ - lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb
286
+ - lib/html2rss/auto_source/scraper/json_state/document_scanner.rb
287
+ - lib/html2rss/auto_source/scraper/json_state/value_finder.rb
300
288
  - lib/html2rss/auto_source/scraper/meta_oembed.rb
301
289
  - lib/html2rss/auto_source/scraper/microdata.rb
302
290
  - lib/html2rss/auto_source/scraper/microformats2.rb
@@ -307,10 +295,17 @@ files:
307
295
  - lib/html2rss/auto_source/scraper/semantic_html.rb
308
296
  - lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb
309
297
  - lib/html2rss/auto_source/scraper/sitemap.rb
298
+ - lib/html2rss/auto_source/scraper/sitemap/parser.rb
310
299
  - lib/html2rss/auto_source/scraper/wordpress_api.rb
311
300
  - lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb
312
301
  - lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb
313
302
  - lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb
303
+ - lib/html2rss/auto_source/segment.rb
304
+ - lib/html2rss/auto_source/segmenter.rb
305
+ - lib/html2rss/auto_source/segmenter/cluster.rb
306
+ - lib/html2rss/auto_source/segmenter/list.rb
307
+ - lib/html2rss/auto_source/segmenter/primary_link.rb
308
+ - lib/html2rss/auto_source/segmenter/semantic.rb
314
309
  - lib/html2rss/channel.rb
315
310
  - lib/html2rss/cli.rb
316
311
  - lib/html2rss/config.rb
@@ -344,15 +339,27 @@ files:
344
339
  - lib/html2rss/html/article_extractor/heading_extractor.rb
345
340
  - lib/html2rss/html/article_extractor/id_generator.rb
346
341
  - lib/html2rss/html/article_extractor/image_extractor.rb
342
+ - lib/html2rss/html/article_rules.rb
343
+ - lib/html2rss/html/article_rules/category.rb
344
+ - lib/html2rss/html/article_rules/date.rb
345
+ - lib/html2rss/html/article_rules/enclosure.rb
346
+ - lib/html2rss/html/article_rules/image.rb
347
347
  - lib/html2rss/html/navigator.rb
348
348
  - lib/html2rss/html/navigator/text_extractor.rb
349
349
  - lib/html2rss/html/rendering.rb
350
350
  - lib/html2rss/html/rendering/audio_renderer.rb
351
351
  - lib/html2rss/html/rendering/description_builder.rb
352
+ - lib/html2rss/html/rendering/escaped_attributes.rb
352
353
  - lib/html2rss/html/rendering/image_renderer.rb
353
354
  - lib/html2rss/html/rendering/media_renderer.rb
354
355
  - lib/html2rss/html/rendering/pdf_renderer.rb
355
356
  - lib/html2rss/html/rendering/video_renderer.rb
357
+ - lib/html2rss/html/sst_article_extractor.rb
358
+ - lib/html2rss/link_destination.rb
359
+ - lib/html2rss/link_destination/destination_facts.rb
360
+ - lib/html2rss/link_destination/noise_policy.rb
361
+ - lib/html2rss/link_destination/path_classifier.rb
362
+ - lib/html2rss/link_destination/text_classifier.rb
356
363
  - lib/html2rss/request_service.rb
357
364
  - lib/html2rss/request_service/blocked_surface.rb
358
365
  - lib/html2rss/request_service/botasaurus_contract.rb
@@ -378,6 +385,15 @@ files:
378
385
  - lib/html2rss/request_session/pager/offset.rb
379
386
  - lib/html2rss/request_session/pager/rel_next.rb
380
387
  - lib/html2rss/request_session/pager/url_template.rb
388
+ - lib/html2rss/scoring.rb
389
+ - lib/html2rss/scoring/anchor_score.rb
390
+ - lib/html2rss/scoring/cluster_scorer.rb
391
+ - lib/html2rss/scoring/container_assessor.rb
392
+ - lib/html2rss/scoring/engine.rb
393
+ - lib/html2rss/scoring/link_resolver.rb
394
+ - lib/html2rss/scoring/observation.rb
395
+ - lib/html2rss/scoring/ranked_segment.rb
396
+ - lib/html2rss/scoring/score.rb
381
397
  - lib/html2rss/selectors.rb
382
398
  - lib/html2rss/selectors/extractors.rb
383
399
  - lib/html2rss/selectors/extractors/attribute.rb
@@ -399,6 +415,14 @@ files:
399
415
  - lib/html2rss/selectors/post_processors/sanitize_html.rb
400
416
  - lib/html2rss/selectors/post_processors/substring.rb
401
417
  - lib/html2rss/selectors/post_processors/template.rb
418
+ - lib/html2rss/sst.rb
419
+ - lib/html2rss/sst/attrs.rb
420
+ - lib/html2rss/sst/document.rb
421
+ - lib/html2rss/sst/index.rb
422
+ - lib/html2rss/sst/node.rb
423
+ - lib/html2rss/sst/normalizer.rb
424
+ - lib/html2rss/sst/tags.rb
425
+ - lib/html2rss/sst/text.rb
402
426
  - lib/html2rss/status.rb
403
427
  - lib/html2rss/url.rb
404
428
  - lib/html2rss/version.rb
@@ -409,7 +433,7 @@ licenses:
409
433
  - MIT
410
434
  metadata:
411
435
  allowed_push_host: https://rubygems.org
412
- changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.24.0
436
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.25.0
413
437
  rubygems_mfa_required: 'true'
414
438
  rdoc_options: []
415
439
  require_paths:
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- module Discovery
6
- class DomClustering
7
- ##
8
- # Scores candidate DOM groups using heading, time, date, and word-count signals.
9
- class GroupScorer
10
- def initialize
11
- @text_words = {}.compare_by_identity
12
- @has_date = {}.compare_by_identity
13
- end
14
-
15
- # @param groups [Hash{String => Array<Nokogiri::XML::Node>}] candidate DOM groups
16
- # @return [Array<Nokogiri::XML::Node>] nodes from the highest-scoring group
17
- def select_best_group(groups)
18
- best_nodes = []
19
- best_score = -1
20
-
21
- groups.each_value do |nodes|
22
- score = score_group(nodes)
23
- next if score.negative?
24
-
25
- (best_nodes = nodes) && (best_score = score) if score > best_score
26
- end
27
-
28
- best_nodes
29
- end
30
-
31
- # @param nodes [Array<Nokogiri::XML::Node>]
32
- # @return [Float] average visible word count across nodes
33
- def avg_words(nodes)
34
- nodes.sum { |n| text_words(n) } / nodes.size.to_f
35
- end
36
-
37
- private
38
-
39
- def score_group(nodes)
40
- avg_w = avg_words(nodes)
41
- return -1 if avg_w < 5
42
-
43
- score = nodes.size + (avg_w / 5.0)
44
- score += 20 if nodes_heading?(nodes)
45
- score += 20 if nodes_time?(nodes)
46
- score += 40 if nodes_date?(nodes)
47
- score
48
- end
49
-
50
- def nodes_heading?(nodes)
51
- nodes.any? do |n|
52
- n.at_css(Html2rss::Html::Navigator::HEADING_TAGS.join(',')) ||
53
- n.at_css('.font-bold, .font-semibold')
54
- end
55
- end
56
-
57
- def nodes_time?(nodes)
58
- nodes.any? { |n| n.at_css('time, [datetime]') }
59
- end
60
-
61
- def nodes_date?(nodes)
62
- nodes.any? { |n| date?(n) }
63
- end
64
-
65
- def text_words(node)
66
- @text_words[node] ||= Html2rss::Html::Navigator.extract_visible_text(node).to_s.scan(/\p{Alnum}+/).size
67
- end
68
-
69
- def date?(node)
70
- @has_date[node] ||= begin
71
- text = Html2rss::Html::Navigator.extract_visible_text(node).to_s
72
- text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
73
- text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
74
- end
75
- end
76
- end
77
- end
78
- end
79
- end
80
- end