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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class Segmenter
6
+ ##
7
+ # Class / structure clustering for anchorless pages
8
+ # (port of Discovery::DomClustering + OverlapResolver; group scoring owned by Scoring::ClusterScorer).
9
+ module Cluster
10
+ module_function
11
+
12
+ ##
13
+ # @param segmenter [Segmenter]
14
+ # @return [Array<Segment>]
15
+ def call(segmenter)
16
+ nodes = best_group_nodes(segmenter)
17
+ nodes.each_with_index.map do |node, position|
18
+ Segment.build(root_node: node, primary_link: nil, strategy: :cluster, position:)
19
+ end
20
+ end
21
+
22
+ def best_group_nodes(segmenter)
23
+ class_groups = collect_class_groups(segmenter)
24
+ groups = class_groups.empty? ? collect_structure_groups(segmenter) : class_groups
25
+ return [] if groups.empty?
26
+
27
+ resolver = OverlapResolver.new(index: segmenter.index)
28
+ scorer = Scoring::ClusterScorer.new
29
+ non_containers = resolver.filter_containers(groups)
30
+ final_groups = resolver.filter_1_to_1_overlap(non_containers)
31
+ scorer.select_best_group(final_groups)
32
+ end
33
+ module_function :best_group_nodes
34
+ private_class_method :best_group_nodes
35
+
36
+ def collect_class_groups(segmenter) # rubocop:disable Metrics/AbcSize
37
+ groups = Hash.new { |h, k| h[k] = [] }
38
+ segmenter.index.each_node do |node|
39
+ next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
40
+ next if segmenter.index.ignored_chrome?(node)
41
+
42
+ cls = normalize_class(node.attrs.class_names)
43
+ groups[cls] << node unless cls.empty?
44
+ end
45
+ groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
46
+ end
47
+ module_function :collect_class_groups
48
+ private_class_method :collect_class_groups
49
+
50
+ def collect_structure_groups(segmenter) # rubocop:disable Metrics/AbcSize
51
+ groups = Hash.new { |h, k| h[k] = [] }
52
+ segmenter.index.each_node do |node|
53
+ next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
54
+ next if segmenter.index.ignored_chrome?(node)
55
+
56
+ sig = structure_signature(node)
57
+ groups[sig] << node unless sig.empty?
58
+ end
59
+ groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
60
+ end
61
+ module_function :collect_structure_groups
62
+ private_class_method :collect_structure_groups
63
+
64
+ def normalize_class(class_names)
65
+ return '' if class_names.empty?
66
+
67
+ class_names.sort.join(' ')
68
+ end
69
+ module_function :normalize_class
70
+ private_class_method :normalize_class
71
+
72
+ def structure_signature(node)
73
+ kids = node.children
74
+ return '' if kids.empty?
75
+
76
+ kids.map { |c| c.name.to_s }.join('>')
77
+ end
78
+ module_function :structure_signature
79
+ private_class_method :structure_signature
80
+
81
+ ##
82
+ # Filters layout wrapper groups and resolves 1-to-1 nested card wrappers.
83
+ class OverlapResolver
84
+ # Cap pairwise work on dense class grids.
85
+ MAX_GROUPS = 40
86
+
87
+ # @param index [SST::Index]
88
+ def initialize(index:)
89
+ @index = index
90
+ @layout_tags = SST::Tags::LAYOUT_NAMES
91
+ @scorer = Scoring::ClusterScorer.new
92
+ end
93
+
94
+ # @param groups [Hash{String => Array<SST::Node>}]
95
+ # @return [Hash{String => Array<SST::Node>}]
96
+ def filter_containers(groups)
97
+ capped = cap_groups(groups)
98
+ capped.reject do |cls_a, nodes_a|
99
+ capped.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
100
+ end
101
+ end
102
+
103
+ # @param groups [Hash{String => Array<SST::Node>}]
104
+ # @return [Hash{String => Array<SST::Node>}]
105
+ def filter_1_to_1_overlap(groups)
106
+ discarded = {}
107
+ groups.each_key do |cls_a|
108
+ groups.each_key do |cls_b|
109
+ next if cls_a == cls_b || discarded[cls_a] || discarded[cls_b]
110
+
111
+ resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
112
+ end
113
+ end
114
+ groups.reject { |cls, _| discarded[cls] }
115
+ end
116
+
117
+ private
118
+
119
+ def cap_groups(groups)
120
+ return groups if groups.size <= MAX_GROUPS
121
+
122
+ groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
123
+ end
124
+
125
+ # rubocop:disable Metrics/MethodLength
126
+ def container_of?(nodes_a, nodes_b)
127
+ return false unless @layout_tags.include?(nodes_b.first.name)
128
+
129
+ nodes_a.any? do |node_a|
130
+ count = 0
131
+ nodes_b.each do |node_b|
132
+ next if node_a.equal?(node_b)
133
+
134
+ if @index.descendant_of?(node_b, node_a)
135
+ count += 1
136
+ break if count > 1
137
+ end
138
+ end
139
+ count > 1
140
+ end
141
+ end
142
+ # rubocop:enable Metrics/MethodLength
143
+
144
+ def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
145
+ nodes_a = groups[cls_a]
146
+ nodes_b = groups[cls_b]
147
+ return if nodes_a.size != nodes_b.size
148
+
149
+ nested = nodes_a.zip(nodes_b).all? { |a, b| !a.equal?(b) && @index.descendant_of?(b, a) }
150
+ return unless nested
151
+
152
+ discarded[keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b] = true
153
+ end
154
+
155
+ def keep_descendant?(nodes_a, nodes_b)
156
+ @scorer.avg_words(nodes_b) >= 0.8 * @scorer.avg_words(nodes_a) &&
157
+ @layout_tags.include?(nodes_b.first.name)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class Segmenter
6
+ ##
7
+ # Builds repeated-list article segments from tag_path frequency
8
+ # (port of Discovery::ListCandidates using tag_path instead of xpath).
9
+ module List
10
+ module_function
11
+
12
+ ##
13
+ # @param segmenter [Segmenter]
14
+ # @return [Array<Segment>]
15
+ def call(segmenter)
16
+ primary = PrimaryLink.new(segmenter)
17
+ pairs = article_pairs(segmenter)
18
+ seen = {}.compare_by_identity
19
+
20
+ pairs.each_with_index.filter_map do |(article_tag, selected_anchor), position|
21
+ next unless seen[article_tag].nil?
22
+
23
+ seen[article_tag] = true
24
+
25
+ link = selected_anchor || primary.select(article_tag)
26
+ next unless link || segmenter.permit_unanchored
27
+
28
+ Segment.build(root_node: article_tag, primary_link: link, strategy: :list, position:)
29
+ end
30
+ end
31
+
32
+ def article_pairs(segmenter)
33
+ by_path = relevant_links_by_path(segmenter)
34
+ top_paths(segmenter, by_path).flat_map do |path|
35
+ by_path.fetch(path, []).filter_map do |node|
36
+ article_tag = parent_until_boundary(segmenter, node)
37
+ next unless article_tag
38
+
39
+ [article_tag, node]
40
+ end
41
+ end
42
+ end
43
+ module_function :article_pairs
44
+ private_class_method :article_pairs
45
+
46
+ def relevant_links_by_path(segmenter)
47
+ by_path = Hash.new { |hash, path| hash[path] = [] }
48
+ segmenter.index.each_node do |node|
49
+ next unless node.link?
50
+ next if segmenter.index.ignored_chrome?(node)
51
+ next unless relevant_anchor?(segmenter, node)
52
+
53
+ by_path[node.tag_path] << node
54
+ end
55
+ by_path
56
+ end
57
+ module_function :relevant_links_by_path
58
+ private_class_method :relevant_links_by_path
59
+
60
+ def top_paths(segmenter, by_path)
61
+ by_path.select { |_path, nodes| nodes.size >= segmenter.minimum_selector_frequency }
62
+ .max_by(segmenter.use_top_selectors) { |_path, nodes| nodes.size }
63
+ .map(&:first)
64
+ end
65
+ module_function :top_paths
66
+ private_class_method :top_paths
67
+
68
+ def relevant_anchor?(segmenter, node)
69
+ facts = segmenter.link_resolver.destination_facts(node)
70
+ return false unless facts
71
+
72
+ text = node.visible_text.to_s.strip
73
+ !segmenter.noise_policy.noise_anchor?(text:, destination_facts: facts, anchor: node)
74
+ end
75
+ module_function :relevant_anchor?
76
+ private_class_method :relevant_anchor?
77
+
78
+ def parent_until_boundary(segmenter, node)
79
+ index = segmenter.index
80
+ link_counts = Hash.new { |hash, curr| hash[curr] = count_links(curr) }
81
+
82
+ index.parent_until(node, lambda { |curr|
83
+ return true if %i[body html].include?(curr.name)
84
+ return false if index.ignored_chrome?(curr)
85
+
86
+ parent = index.parent_of(curr)
87
+ parent && link_counts[parent] > link_counts[curr]
88
+ })
89
+ end
90
+ module_function :parent_until_boundary
91
+ private_class_method :parent_until_boundary
92
+
93
+ def count_links(node)
94
+ node.link? ? 1 : node.descendants.count(&:link?)
95
+ end
96
+ module_function :count_links
97
+ private_class_method :count_links
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class Segmenter
6
+ ##
7
+ # Selects the strongest content-like primary link inside a container
8
+ # (port of Discovery::SemanticAnchorCandidates).
9
+ class PrimaryLink
10
+ # @param segmenter [Segmenter]
11
+ def initialize(segmenter)
12
+ @segmenter = segmenter
13
+ @index = segmenter.index
14
+ @link_resolver = segmenter.link_resolver
15
+ @noise_policy = segmenter.noise_policy
16
+ end
17
+
18
+ ##
19
+ # @param container [SST::Node]
20
+ # @return [SST::Node, nil]
21
+ def select(container) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
22
+ winners = {}
23
+
24
+ container.find_all(&:link?).each do |anchor|
25
+ next if @index.ignored_chrome?(anchor)
26
+
27
+ facts = candidate_facts(anchor, container)
28
+ next unless facts
29
+
30
+ dest = facts[:destination]
31
+ current = winners[dest]
32
+ winners[dest] = facts if current.nil? || facts[:score] > current[:score]
33
+ end
34
+
35
+ winners.values.max_by { |f| f[:score] }&.fetch(:anchor)
36
+ end
37
+
38
+ private
39
+
40
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
41
+ def candidate_facts(anchor, container)
42
+ destination = @link_resolver.destination_facts(anchor)
43
+ return unless destination
44
+
45
+ text = anchor.visible_text.to_s.strip
46
+ heading = first_heading(container)
47
+ heading_text = heading ? heading.visible_text.to_s.strip : ''
48
+ heading_anchor = heading_anchor?(anchor, heading)
49
+ meaningful = text.match?(/\p{Alnum}/)
50
+ content_like = destination.content_path
51
+ heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
52
+
53
+ return if @noise_policy.noise_anchor?(
54
+ text:, destination_facts: destination, anchor:, container:, heading_anchor:
55
+ )
56
+ return unless meaningful || content_like || heading_anchor
57
+
58
+ score = Scoring::AnchorScore.score(
59
+ heading_anchor:,
60
+ heading_text_match: heading_match,
61
+ meaningful_text: meaningful,
62
+ content_like_destination: content_like
63
+ )
64
+
65
+ { anchor:, destination: destination.destination, score: }
66
+ end
67
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
68
+
69
+ def first_heading(container)
70
+ (@headings ||= {}.compare_by_identity)[container] ||= container.find(&:heading?)
71
+ end
72
+
73
+ def heading_anchor?(anchor, heading)
74
+ return false unless heading
75
+
76
+ anchor.equal?(heading) || @index.descendant_of?(anchor, heading)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class Segmenter
6
+ ##
7
+ # Collects leaf semantic containers (port of Discovery::SemanticContainers).
8
+ module Semantic
9
+ # Tag names collected as leaf semantic containers.
10
+ CANDIDATE_NAMES = %i[article section li tr div].to_set.freeze
11
+
12
+ module_function
13
+
14
+ ##
15
+ # @param segmenter [Segmenter]
16
+ # @return [Array<Segment>]
17
+ def call(segmenter)
18
+ containers = collect_containers(segmenter.index)
19
+ primary = PrimaryLink.new(segmenter)
20
+
21
+ containers.each_with_index.filter_map do |container, position|
22
+ link = primary.select(container)
23
+ next unless link || segmenter.permit_unanchored
24
+
25
+ Segment.build(root_node: container, primary_link: link, strategy: :semantic, position:)
26
+ end
27
+ end
28
+
29
+ def collect_containers(index)
30
+ non_leaf = mark_non_leaf_candidates(index.root)
31
+ candidates = []
32
+ index.each_node do |node|
33
+ next unless CANDIDATE_NAMES.include?(node.name)
34
+ next if non_leaf.include?(node)
35
+ next if index.ignored_chrome?(node)
36
+
37
+ candidates << node
38
+ end
39
+
40
+ candidates = filter_nested(candidates, index)
41
+ sort_by_depth(candidates, index)
42
+ end
43
+ module_function :collect_containers
44
+ private_class_method :collect_containers
45
+
46
+ TAG_MASKS = { article: 1, section: 2, li: 4, tr: 8, div: 16 }.freeze
47
+ private_constant :TAG_MASKS
48
+
49
+ # Single O(N) post-order pass: mark candidate nodes that contain a same-name candidate.
50
+ def mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
51
+ non_leaf = Set.new.compare_by_identity
52
+ visit = lambda do |node|
53
+ seen_mask = 0
54
+ node.children.each do |child|
55
+ child_mask = visit.call(child)
56
+ seen_mask |= child_mask | (TAG_MASKS[child.name] || 0)
57
+ end
58
+ node_mask = TAG_MASKS[node.name]
59
+ non_leaf.add(node) if node_mask && seen_mask.anybits?(node_mask)
60
+ seen_mask
61
+ end
62
+ visit.call(root)
63
+ non_leaf
64
+ end
65
+ module_function :mark_non_leaf_candidates
66
+ private_class_method :mark_non_leaf_candidates
67
+
68
+ def filter_nested(candidates, index)
69
+ candidate_set = {}.compare_by_identity
70
+ candidates.each { |c| candidate_set[c] = true }
71
+ rejected = {}.compare_by_identity
72
+
73
+ candidates.each do |candidate|
74
+ next if candidate.name == :div
75
+
76
+ reject_ancestors(candidate, candidate_set, rejected, index)
77
+ end
78
+
79
+ candidates.reject { |c| rejected[c] }
80
+ end
81
+ module_function :filter_nested
82
+ private_class_method :filter_nested
83
+
84
+ def reject_ancestors(node, candidate_set, rejected, index)
85
+ curr = index.parent_of(node)
86
+ while curr && curr.name != :html
87
+ rejected[curr] = true if candidate_set[curr]
88
+ curr = index.parent_of(curr)
89
+ end
90
+ end
91
+ module_function :reject_ancestors
92
+ private_class_method :reject_ancestors
93
+
94
+ def sort_by_depth(candidates, index)
95
+ candidates.each_with_index
96
+ .sort_by { |node, i| [-index.depth_of(node), i] }
97
+ .map(&:first)
98
+ end
99
+ module_function :sort_by_depth
100
+ private_class_method :sort_by_depth
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ ##
6
+ # Discovers candidate content segments from an SST::Document.
7
+ #
8
+ # Strategies:
9
+ # - +:semantic+ — leaf article/section/li/tr/div containers with a primary link
10
+ # - +:list+ — repeated tag_path anchors walked to a shared container boundary
11
+ # - +:cluster+ — class / structure clustering for anchorless card grids
12
+ class Segmenter
13
+ # @param document [SST::Document]
14
+ # @param base_url [String, Html2rss::Url]
15
+ # @param strategy [Symbol]
16
+ # @param opts [Hash] segmentation options
17
+ # @option opts [Boolean] :permit_unanchored keep containers without a primary link
18
+ # @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
19
+ # @option opts [Integer] :use_top_selectors list strategy selector budget
20
+ # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
21
+ # @return [Array<Segment>]
22
+ # rubocop:disable Style/ArgumentsForwarding -- keep named opts for YARD @option
23
+ def self.call(document, base_url:, strategy:, **opts)
24
+ new(document, base_url:, strategy:, **opts).call
25
+ end
26
+ # rubocop:enable Style/ArgumentsForwarding
27
+
28
+ # @param document [SST::Document]
29
+ # @param base_url [String, Html2rss::Url]
30
+ # @param strategy [Symbol]
31
+ # @param opts [Hash] segmentation options
32
+ # @option opts [Boolean] :permit_unanchored keep containers without a primary link
33
+ # @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
34
+ # @option opts [Integer] :use_top_selectors list strategy selector budget
35
+ # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
36
+ def initialize(document, base_url:, strategy:, **opts)
37
+ raise ArgumentError, 'document must be SST::Document' unless document.is_a?(SST::Document)
38
+ raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
39
+
40
+ @document = document
41
+ @base_url = base_url
42
+ @strategy = strategy
43
+ @permit_unanchored = opts.fetch(:permit_unanchored, false)
44
+ @minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
45
+ @use_top_selectors = opts.fetch(:use_top_selectors, 5)
46
+ @link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
47
+ @noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver, index: document.index)
48
+ end
49
+
50
+ # @return [Array<Segment>]
51
+ def call
52
+ case @strategy
53
+ when :semantic then Semantic.call(self)
54
+ when :list then List.call(self)
55
+ when :cluster then Cluster.call(self)
56
+ end
57
+ end
58
+
59
+ attr_reader :document, :base_url, :permit_unanchored, :minimum_selector_frequency,
60
+ :use_top_selectors, :link_resolver, :noise_policy
61
+
62
+ # @return [SST::Index]
63
+ def index = document.index
64
+ end
65
+ end
66
+ end
@@ -15,17 +15,22 @@ module Html2rss
15
15
  # @see Html2rss::AutoSource::Scraper::Schema
16
16
  # @see Html2rss::AutoSource::Scraper::SemanticHtml
17
17
  # @see Html2rss::AutoSource::Scraper::Html
18
+ # rubocop:disable Metrics/ClassLength -- defaults + tiered extract stay on the contributor entry type
18
19
  class AutoSource
20
+ # Default max articles to keep (also the short-circuit floor across scraper tiers).
21
+ DEFAULT_LIMIT = 25
22
+
19
23
  # Default auto-source configuration shipped for scraper and cleanup behavior.
20
24
  DEFAULT_CONFIG = {
25
+ limit: DEFAULT_LIMIT,
21
26
  scraper: {
22
27
  wordpress_api: {
23
28
  enabled: true
24
29
  },
25
30
  sitemap: {
26
31
  enabled: true,
27
- min_priority: Discovery::Sitemap::DEFAULT_MIN_PRIORITY,
28
- max_age_days: Discovery::Sitemap::DEFAULT_MAX_AGE_DAYS
32
+ min_priority: Scraper::Sitemap::Parser::DEFAULT_MIN_PRIORITY,
33
+ max_age_days: Scraper::Sitemap::Parser::DEFAULT_MAX_AGE_DAYS
29
34
  },
30
35
  schema: {
31
36
  enabled: true
@@ -80,28 +85,24 @@ module Html2rss
80
85
  # @param response [Html2rss::RequestService::Response] initial page response
81
86
  # @param opts [Hash] validated auto-source options
82
87
  # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
88
+ # @option opts [Integer] :limit max articles to keep; later tiers stop once this many survive Cleanup
83
89
  # @option opts [Hash] :scraper scraper configuration map
84
90
  # @option opts [Hash] :cleanup cleanup configuration map
85
91
  # @return [void]
86
92
  def initialize(response, opts = DEFAULT_CONFIG, request_session: nil)
87
93
  @parsed_body = response.parsed_body
94
+ @body = response.body
88
95
  @url = response.url
89
96
  @opts = opts
90
97
  @request_session = request_session
91
98
  end
92
99
 
93
100
  ##
94
- # Extracts article candidates by selecting every scraper that can explain the
95
- # page shape, running those scrapers, and normalizing the resulting hashes
96
- # into `Article` objects.
97
- #
98
- # The contributor-facing flow is:
99
- # 1. choose scraper instances that match the page
100
- # 2. let each scraper collect its own candidates
101
- # 3. clean and deduplicate the merged article list
101
+ # Extracts articles by running scraper tiers until a sufficient set is found.
102
102
  #
103
- # Scrapers with expensive precomputation, such as `SemanticHtml`, keep that
104
- # state on the instance so detection and extraction can reuse the same work.
103
+ # Tiers: in-page structured follow-up IO SemanticHtml Html.
104
+ # SST is built only when a heuristic tier runs. Later tiers are skipped once
105
+ # +limit+ articles with url+title remain after Cleanup; the result is capped to +limit+.
105
106
  #
106
107
  # @return [Array<Html2rss::Article>] extracted articles
107
108
  def articles
@@ -113,22 +114,73 @@ module Html2rss
113
114
 
114
115
  private
115
116
 
116
- attr_reader :url, :parsed_body, :request_session
117
+ attr_reader :url, :parsed_body, :body, :request_session
117
118
 
119
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
118
120
  def extract_articles
119
- scraper_instances = Scraper.instances_for(parsed_body, url:, request_session:, opts: @opts[:scraper])
120
- return [] if scraper_instances.empty?
121
+ articles = []
122
+ matched = false
123
+ document = nil
124
+ link_resolver = nil
125
+ scraper_opts = @opts.fetch(:scraper, {})
126
+
127
+ Scraper::SCRAPER_TIERS.each do |tier|
128
+ break if enough_articles?(articles)
129
+
130
+ if Scraper.heuristic_tier?(tier)
131
+ document ||= Scraper.normalize_sst(parsed_body)
132
+ next unless document
121
133
 
122
- # Scrapers are run sequentially.
123
- articles = scraper_instances.flat_map do |instance|
124
- run_scraper(instance)
134
+ link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
135
+ end
136
+
137
+ tier.each do |scraper_class|
138
+ instance = Scraper.build_instance(
139
+ scraper_class,
140
+ parsed_body,
141
+ opts: scraper_opts,
142
+ url:,
143
+ request_session:,
144
+ body:,
145
+ document:,
146
+ link_resolver:
147
+ )
148
+ next unless instance
149
+ next unless Scraper.extractable_instance?(instance, parsed_body)
150
+
151
+ matched = true
152
+ articles.concat(run_scraper(instance))
153
+ end
125
154
  end
126
- Cleanup.call(articles, url:, **cleanup_options)
155
+
156
+ raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
157
+
158
+ Cleanup.call(articles, url:, **cleanup_options).first(article_limit)
159
+ end
160
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
161
+
162
+ def enough_articles?(articles)
163
+ threshold = article_limit
164
+ return false if articles.size < threshold
165
+
166
+ cleaned = Cleanup.call(articles.dup, url:, **cleanup_options)
167
+ cleaned.count { |article| article.url && !article.title.to_s.empty? } >= threshold
168
+ end
169
+
170
+ def article_limit
171
+ @opts.fetch(:limit, DEFAULT_LIMIT)
127
172
  end
128
173
 
129
174
  def run_scraper(instance)
130
- instance.each.map do |article_hash|
131
- Article.new(**article_hash, scraper: instance.class)
175
+ instance.each.map do |item|
176
+ case item
177
+ when Article
178
+ item
179
+ when Hash
180
+ Article.new(**item, scraper: instance.class)
181
+ else
182
+ raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
183
+ end
132
184
  end
133
185
  end
134
186
 
@@ -136,4 +188,5 @@ module Html2rss
136
188
  @opts.fetch(:cleanup, {})
137
189
  end
138
190
  end
191
+ # rubocop:enable Metrics/ClassLength
139
192
  end