html2rss 0.22.2 → 0.24.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -108
  3. data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
  4. data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
  5. data/lib/html2rss/article.rb +213 -0
  6. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
  7. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
  8. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
  9. data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
  10. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
  11. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
  12. data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
  13. data/lib/html2rss/auto_source/discovery.rb +14 -0
  14. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
  15. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
  16. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
  17. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
  18. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
  19. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
  20. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
  21. data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
  22. data/lib/html2rss/auto_source/scraper/html.rb +22 -33
  23. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  24. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
  25. data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
  29. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  30. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  32. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  33. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  35. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  36. data/lib/html2rss/auto_source/scraper.rb +10 -3
  37. data/lib/html2rss/auto_source.rb +30 -35
  38. data/lib/html2rss/channel.rb +198 -0
  39. data/lib/html2rss/cli.rb +5 -6
  40. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  41. data/lib/html2rss/config/request_controls.rb +136 -0
  42. data/lib/html2rss/config/request_headers.rb +1 -1
  43. data/lib/html2rss/config/schema.rb +65 -114
  44. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  45. data/lib/html2rss/config/validator.rb +6 -6
  46. data/lib/html2rss/config.rb +176 -19
  47. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  48. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  49. data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
  50. data/lib/html2rss/feed_builder/json_feed.rb +81 -0
  51. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  52. data/lib/html2rss/feed_builder/rss.rb +116 -0
  53. data/lib/html2rss/feed_builder.rb +15 -0
  54. data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
  55. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  56. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  57. data/lib/html2rss/feed_pipeline.rb +83 -89
  58. data/lib/html2rss/feed_result.rb +92 -0
  59. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  60. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  61. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  62. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  63. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  64. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  65. data/lib/html2rss/html/article_extractor.rb +134 -0
  66. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  67. data/lib/html2rss/html/navigator.rb +146 -0
  68. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  70. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  71. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  72. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  73. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  74. data/lib/html2rss/html/rendering.rb +25 -0
  75. data/lib/html2rss/html.rb +8 -0
  76. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  77. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
  78. data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
  79. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  80. data/lib/html2rss/request_service/budget.rb +97 -18
  81. data/lib/html2rss/request_service/context.rb +1 -3
  82. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  83. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  84. data/lib/html2rss/request_service/network_guard.rb +125 -0
  85. data/lib/html2rss/request_service/policy.rb +10 -82
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  88. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  89. data/lib/html2rss/request_service/response.rb +9 -1
  90. data/lib/html2rss/request_service/response_guard.rb +1 -1
  91. data/lib/html2rss/request_service/strategy.rb +123 -5
  92. data/lib/html2rss/request_service.rb +7 -2
  93. data/lib/html2rss/request_session/pager/base.rb +83 -0
  94. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  95. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  96. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  97. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  98. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  99. data/lib/html2rss/request_session/pager.rb +75 -0
  100. data/lib/html2rss/request_session.rb +29 -16
  101. data/lib/html2rss/selectors/item_scope.rb +30 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  104. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  105. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  106. data/lib/html2rss/selectors.rb +84 -64
  107. data/lib/html2rss/status.rb +132 -0
  108. data/lib/html2rss/version.rb +1 -3
  109. data/lib/html2rss.rb +34 -22
  110. data/schema/html2rss-config.schema.json +109 -9
  111. metadata +71 -43
  112. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  113. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  114. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
  115. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  116. data/lib/html2rss/blocked_surface.rb +0 -65
  117. data/lib/html2rss/category_extractor.rb +0 -116
  118. data/lib/html2rss/config/class_methods.rb +0 -183
  119. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  120. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  121. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  122. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  123. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  124. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  125. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  126. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  127. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  128. data/lib/html2rss/html_extractor.rb +0 -190
  129. data/lib/html2rss/html_navigator.rb +0 -71
  130. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  131. data/lib/html2rss/json_feed_builder.rb +0 -59
  132. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  133. data/lib/html2rss/rendering/description_builder.rb +0 -87
  134. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  135. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  136. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  137. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  138. data/lib/html2rss/rendering.rb +0 -23
  139. data/lib/html2rss/request_controls.rb +0 -133
  140. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  141. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  142. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  143. data/lib/html2rss/rss_builder/article.rb +0 -208
  144. data/lib/html2rss/rss_builder/channel.rb +0 -118
  145. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  146. data/lib/html2rss/rss_builder.rb +0 -101
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Discovery
6
+ ##
7
+ # Discovers repeated content card nodes in anchorless or classless DOM trees
8
+ # by evaluating class groups first and falling back to 1-level tag structure signatures.
9
+ class DomClustering
10
+ # Node tags considered layout containers
11
+ LAYOUT_TAG_NAMES = Set['div', 'section', 'article', 'li', 'ul', 'ol'].freeze
12
+ # HTML/layout tags excluded from candidate nodes (owned by Html::Navigator).
13
+ EXCLUDED_TAGS = Html2rss::Html::Navigator::CLUSTER_EXCLUDED_TAGS
14
+
15
+ class << self
16
+ ##
17
+ # Clusters elements in parsed_body and returns the best set of content card nodes.
18
+ #
19
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
20
+ # @param minimum_selector_frequency [Integer] minimum frequency for candidate groups
21
+ # @return [Array<Nokogiri::XML::Node>] candidate nodes of the top-scoring cluster
22
+ def call(parsed_body, minimum_selector_frequency:)
23
+ new(parsed_body, minimum_frequency: minimum_selector_frequency).call
24
+ end
25
+ end
26
+
27
+ # @param parsed_body [Nokogiri::HTML::Document]
28
+ # @param minimum_frequency [Integer]
29
+ def initialize(parsed_body, minimum_frequency:)
30
+ @parsed_body = parsed_body
31
+ @minimum_frequency = minimum_frequency
32
+ @cache = {}.compare_by_identity
33
+ end
34
+
35
+ # @return [Array<Nokogiri::XML::Node>]
36
+ def call
37
+ class_result = cluster_by_class
38
+ return class_result unless class_result.empty?
39
+
40
+ cluster_by_structure
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :parsed_body, :minimum_frequency, :cache
46
+
47
+ def cluster_by_class
48
+ class_groups = collect_class_groups
49
+ return [] if class_groups.empty?
50
+
51
+ score_groups(class_groups)
52
+ end
53
+
54
+ def cluster_by_structure
55
+ structure_groups = collect_structure_groups
56
+ return [] if structure_groups.empty?
57
+
58
+ score_groups(structure_groups)
59
+ end
60
+
61
+ def score_groups(candidate_groups)
62
+ scorer = GroupScorer.new
63
+ resolver = OverlapResolver.new(layout_tags: LAYOUT_TAG_NAMES, word_counter: scorer)
64
+
65
+ non_containers = resolver.filter_containers(candidate_groups)
66
+ final_groups = resolver.filter_1_to_1_overlap(non_containers)
67
+
68
+ scorer.select_best_group(final_groups)
69
+ end
70
+
71
+ def collect_class_groups
72
+ groups = Hash.new { |h, k| h[k] = [] }
73
+ parsed_body.css('[class]').each { |node| add_class_node(node, groups) }
74
+ groups.select { |_, nodes| nodes.size >= minimum_frequency }
75
+ end
76
+
77
+ def add_class_node(node, groups)
78
+ return if EXCLUDED_TAGS.include?(node.name)
79
+ return if Html2rss::Html::Navigator.ignored_container_path?(node, cache)
80
+
81
+ cls = normalize_class(node['class'])
82
+ groups[cls] << node unless cls.empty?
83
+ end
84
+
85
+ def collect_structure_groups
86
+ groups = Hash.new { |h, k| h[k] = [] }
87
+ parsed_body.xpath('//*').each { |node| add_structure_node(node, groups) }
88
+ groups.select { |_, nodes| nodes.size >= minimum_frequency }
89
+ end
90
+
91
+ def add_structure_node(node, groups)
92
+ return if EXCLUDED_TAGS.include?(node.name)
93
+ return if Html2rss::Html::Navigator.ignored_container_path?(node, cache)
94
+
95
+ sig = structure_signature(node)
96
+ groups[sig] << node unless sig.empty?
97
+ end
98
+
99
+ def normalize_class(class_attr)
100
+ class_str = class_attr.to_s.strip
101
+ return '' if class_str.empty?
102
+
103
+ if class_str.include?(' ')
104
+ class_str.split(/\s+/).sort.join(' ')
105
+ else
106
+ class_str
107
+ end
108
+ end
109
+
110
+ def structure_signature(node)
111
+ child_elements = node.element_children
112
+ return '' if child_elements.empty?
113
+
114
+ child_elements.map(&:name).join('>')
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Discovery
6
+ ##
7
+ # Builds repeated-list article container candidates from generic HTML.
8
+ class ListCandidates
9
+ ##
10
+ # Simplify an XPath selector by removing index notation.
11
+ #
12
+ # @param xpath [String] original XPath
13
+ # @return [String] XPath without positional indexes
14
+ def self.simplify_xpath(xpath)
15
+ xpath.gsub(/\[\d+\]/, '')
16
+ end
17
+
18
+ # @param parsed_body [Nokogiri::HTML::Document] parsed document
19
+ # @param minimum_selector_frequency [Integer] minimum repeated anchor path count
20
+ # @param use_top_selectors [Integer] number of frequent anchor paths to inspect
21
+ def initialize(parsed_body, minimum_selector_frequency:, use_top_selectors:)
22
+ @parsed_body = parsed_body
23
+ @minimum_selector_frequency = minimum_selector_frequency
24
+ @use_top_selectors = use_top_selectors
25
+ end
26
+
27
+ ##
28
+ # @param anchor_filter [#call] predicate for scraper-specific anchor eligibility
29
+ # @param boundary_condition [#call] predicate for article container boundary
30
+ # @yieldparam article_tag [Nokogiri::XML::Node] candidate article container
31
+ # @yieldparam selected_anchor [Nokogiri::XML::Node] anchor that made the container eligible
32
+ # @return [Enumerator]
33
+ def each_article_tag(anchor_filter:, boundary_condition:)
34
+ return enum_for(:each_article_tag, anchor_filter:, boundary_condition:) unless block_given?
35
+
36
+ article_tags(anchor_filter:, boundary_condition:).each { yield _1[:article_tag], _1[:selected_anchor] }
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :parsed_body, :minimum_selector_frequency, :use_top_selectors
42
+
43
+ def article_tags(anchor_filter:, boundary_condition:)
44
+ cache = {}.compare_by_identity
45
+ selectors(anchor_filter:).flat_map do |selector|
46
+ article_tags_for_selector(selector, boundary_condition, cache)
47
+ end
48
+ end
49
+
50
+ def article_tags_for_selector(selector, boundary_condition, cache = {}.compare_by_identity)
51
+ parsed_body.xpath(selector).filter_map do |selected_tag|
52
+ next if Html2rss::Html::Navigator.ignored_container_path?(selected_tag, cache)
53
+
54
+ article_tag = Html2rss::Html::Navigator.parent_until_condition(selected_tag, boundary_condition)
55
+ next unless article_tag
56
+
57
+ { article_tag:, selected_anchor: selected_tag }
58
+ end
59
+ end
60
+
61
+ def selectors(anchor_filter:)
62
+ anchor_counts(anchor_filter:)
63
+ .select { |_selector, count| count >= minimum_selector_frequency }
64
+ .max_by(use_top_selectors, &:last)
65
+ .map(&:first)
66
+ end
67
+
68
+ def anchor_counts(anchor_filter:)
69
+ cache = {}.compare_by_identity
70
+ Hash.new(0).tap do |counts|
71
+ each_anchor(anchor_filter:) do |node|
72
+ next if Html2rss::Html::Navigator.ignored_container_path?(node, cache)
73
+
74
+ path = self.class.simplify_xpath(node.path)
75
+ counts[path] += 1
76
+ end
77
+ end
78
+ end
79
+
80
+ def each_anchor(anchor_filter:)
81
+ return enum_for(:each_anchor, anchor_filter:) unless block_given?
82
+
83
+ traversal_root&.css(Html2rss::Html::Navigator::MAIN_ANCHOR_SELECTOR)&.each do |node|
84
+ yield node if anchor_filter.call(node)
85
+ end
86
+ end
87
+
88
+ def traversal_root
89
+ parsed_body.at_css('body, html') || parsed_body.root
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Discovery
6
+ ##
7
+ # Builds ranked anchor facts for one semantic content container.
8
+ class SemanticAnchorCandidates
9
+ # Anchor candidate plus scoring signals used by semantic anchor ranking.
10
+ AnchorFacts = Data.define(
11
+ :anchor,
12
+ :text,
13
+ :url,
14
+ :destination,
15
+ :segments,
16
+ :meaningful_text,
17
+ :content_like_destination,
18
+ :heading_anchor,
19
+ :heading_text_match,
20
+ :score
21
+ ) do
22
+ # @param candidate [Candidate] eligible semantic anchor candidate
23
+ # @return [AnchorFacts] serializable facts used for ranking and dedupe
24
+ def self.from_candidate(candidate)
25
+ signals = candidate.anchor_signals
26
+
27
+ new(
28
+ **candidate.anchor_identity_attributes,
29
+ meaningful_text: signals.meaningful_text,
30
+ content_like_destination: signals.content_like_destination,
31
+ heading_anchor: signals.heading_anchor,
32
+ heading_text_match: signals.heading_text_match,
33
+ score: signals.score
34
+ )
35
+ end
36
+ end
37
+
38
+ # Shared context for all anchors in one semantic container.
39
+ class Context
40
+ attr_reader :container, :link_heuristics
41
+
42
+ # @param container [Nokogiri::XML::Node] semantic container
43
+ # @param link_heuristics [Html2rss::AutoSource::LinkHeuristics] destination/text heuristics
44
+ def initialize(container, link_heuristics:)
45
+ @container = container
46
+ @link_heuristics = link_heuristics
47
+ end
48
+
49
+ # @return [Nokogiri::XML::Node, nil] heading used to identify title anchors
50
+ def heading
51
+ @heading ||= @container.at_css(Html2rss::Html::Navigator::HEADING_TAGS.join(','))
52
+ end
53
+
54
+ # @return [String] visible heading text
55
+ def heading_text
56
+ @heading_text ||= visible_text(heading)
57
+ end
58
+
59
+ # @param node [Nokogiri::XML::Node, nil] node to extract text from
60
+ # @return [String] visible text for the node
61
+ def visible_text(node)
62
+ return '' unless node
63
+
64
+ (@visible_texts ||= {}.compare_by_identity)[node] ||= Html2rss::Html::Navigator.extract_visible_text(node).to_s.strip
65
+ end
66
+
67
+ # @param anchor [Nokogiri::XML::Node] anchor candidate
68
+ # @return [Html2rss::AutoSource::LinkHeuristics::DestinationFacts, nil] destination facts
69
+ def destination_facts(anchor)
70
+ @link_heuristics.destination_facts(anchor)
71
+ end
72
+ end
73
+
74
+ # One anchor plus the facts needed to decide whether it represents content.
75
+ class Candidate
76
+ attr_reader :anchor
77
+
78
+ # @param anchor [Nokogiri::XML::Node] anchor candidate
79
+ # @param context [Context] semantic container context
80
+ def initialize(anchor, context)
81
+ @anchor = anchor
82
+ @context = context
83
+ end
84
+
85
+ # @return [AnchorFacts, nil] ranked anchor facts when the anchor is eligible
86
+ def facts
87
+ return unless destination_facts
88
+ return if noise_anchor?
89
+ return unless representative_content_anchor?
90
+
91
+ AnchorFacts.from_candidate(self)
92
+ end
93
+
94
+ # @return [Html2rss::AutoSource::LinkHeuristics::DestinationFacts, nil] destination facts
95
+ def destination_facts
96
+ @destination_facts ||= @context.destination_facts(@anchor)
97
+ end
98
+
99
+ # @return [String] visible anchor text
100
+ def text
101
+ @text ||= @context.visible_text(@anchor)
102
+ end
103
+
104
+ # @return [Hash] anchor identity attributes used to build AnchorFacts
105
+ def anchor_identity_attributes
106
+ {
107
+ anchor:,
108
+ text:,
109
+ url: destination_facts.url,
110
+ destination: destination_facts.destination,
111
+ segments: destination_facts.segments
112
+ }
113
+ end
114
+
115
+ # @return [Html2rss::AutoSource::LinkHeuristics::AnchorSignals] ranking signals
116
+ def anchor_signals
117
+ LinkHeuristics::AnchorSignals.new(
118
+ meaningful_text: meaningful_text?,
119
+ content_like_destination: content_like_destination?,
120
+ heading_anchor: heading_anchor?,
121
+ heading_text_match: heading_text_match?
122
+ )
123
+ end
124
+
125
+ # @return [Boolean] true when visible anchor text has words
126
+ def meaningful_text?
127
+ @meaningful_text ||= text.match?(/\p{Alnum}/)
128
+ end
129
+
130
+ # @return [Boolean] true when the destination route has content signals
131
+ def content_like_destination?
132
+ destination_facts.content_path
133
+ end
134
+
135
+ # @return [Boolean] true when the anchor is inside the selected heading
136
+ def heading_anchor?
137
+ heading = @context.heading
138
+ heading && (@anchor == heading || Html2rss::Html::Navigator.descendant_of?(@anchor, heading))
139
+ end
140
+
141
+ # @return [Boolean] true when anchor text exactly matches heading text
142
+ def heading_text_match?
143
+ heading_text = @context.heading_text
144
+
145
+ meaningful_text? &&
146
+ heading_text.match?(/\p{Alnum}/) &&
147
+ heading_text == text
148
+ end
149
+
150
+ private
151
+
152
+ def representative_content_anchor?
153
+ meaningful_text? || content_like_destination? || heading_anchor?
154
+ end
155
+
156
+ def noise_anchor?
157
+ @context.link_heuristics.noise_anchor?(
158
+ text:,
159
+ destination_facts:,
160
+ anchor: @anchor,
161
+ container: @context.container,
162
+ heading_anchor: heading_anchor?
163
+ )
164
+ end
165
+ end
166
+
167
+ # Keeps the strongest semantic anchor fact for each destination.
168
+ class DestinationWinners
169
+ def initialize
170
+ @winners = {}
171
+ end
172
+
173
+ # @return [Array<AnchorFacts>] strongest candidate per destination
174
+ def to_a
175
+ @winners.values
176
+ end
177
+
178
+ # @param facts [AnchorFacts] candidate anchor facts
179
+ # @return [void]
180
+ def add(facts)
181
+ destination = facts.destination
182
+ @winners[destination] = stronger_fact(@winners[destination], facts)
183
+ end
184
+
185
+ private
186
+
187
+ def stronger_fact(current, candidate)
188
+ return candidate unless current
189
+
190
+ current.score >= candidate.score ? current : candidate
191
+ end
192
+ end
193
+
194
+ # @param container [Nokogiri::XML::Node] semantic container
195
+ # @param link_heuristics [Html2rss::AutoSource::LinkHeuristics] destination/text heuristics
196
+ def initialize(container, link_heuristics:)
197
+ @container = container
198
+ @context = Context.new(container, link_heuristics:)
199
+ end
200
+
201
+ # @return [Array<AnchorFacts>] strongest candidate per destination
202
+ def to_a
203
+ @container.css(Html2rss::Html::Navigator::MAIN_ANCHOR_SELECTOR)
204
+ .each_with_object(DestinationWinners.new) { |anchor, winners| add_anchor(anchor, winners) }
205
+ .to_a
206
+ end
207
+
208
+ private
209
+
210
+ def add_anchor(anchor, winners)
211
+ return if Html2rss::Html::Navigator.ignored_container_path?(anchor)
212
+
213
+ facts = Candidate.new(anchor, @context).facts
214
+ winners.add(facts) if facts
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Discovery
6
+ ##
7
+ # Collects semantic content containers from a parsed HTML document.
8
+ class SemanticContainers
9
+ # Candidate selectors used to locate extractable semantic content blocks.
10
+ SELECTORS = [
11
+ 'article:not(:has(article))',
12
+ 'section:not(:has(section))',
13
+ 'li:not(:has(li))',
14
+ 'tr:not(:has(tr))',
15
+ 'div:not(:has(div))'
16
+ ].freeze
17
+
18
+ # @param parsed_body [Nokogiri::HTML::Document] parsed document
19
+ # @return [Array<Nokogiri::XML::Node>] candidate semantic containers
20
+ def self.call(parsed_body)
21
+ new(parsed_body).call
22
+ end
23
+
24
+ # @param parsed_body [Nokogiri::HTML::Document] parsed document
25
+ def initialize(parsed_body)
26
+ @parsed_body = parsed_body
27
+ end
28
+
29
+ # @return [Array<Nokogiri::XML::Node>] candidate semantic containers
30
+ def call
31
+ cache = {}.compare_by_identity
32
+ candidates = @parsed_body.css(SELECTORS.join(',')).reject do |node|
33
+ Html2rss::Html::Navigator.ignored_container_path?(node, cache)
34
+ end
35
+
36
+ candidates = filter_nested_containers(candidates)
37
+ sort_by_depth(candidates)
38
+ end
39
+
40
+ private
41
+
42
+ def filter_nested_containers(candidates)
43
+ candidate_set = Set.new(candidates)
44
+ rejected = Set.new
45
+
46
+ candidates.each do |candidate_b|
47
+ next if candidate_b.name == 'div'
48
+
49
+ find_and_reject_ancestors(candidate_b, candidate_set, rejected)
50
+ end
51
+
52
+ candidates.reject { |c| rejected.include?(c) }
53
+ end
54
+
55
+ def find_and_reject_ancestors(node, candidate_set, rejected)
56
+ curr = node.parent
57
+ while curr && !curr.document? && curr.name != 'html'
58
+ rejected << curr if candidate_set.include?(curr)
59
+ curr = curr.parent
60
+ end
61
+ end
62
+
63
+ def sort_by_depth(candidates)
64
+ candidates.each_with_index
65
+ .sort_by { |node, index| [-node.ancestors.size, index] }
66
+ .map!(&:first)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'nokogiri'
5
+
6
+ module Html2rss
7
+ class AutoSource
8
+ module Discovery
9
+ ##
10
+ # Parses XML sitemap documents and extracts prioritized, recent article entries.
11
+ class Sitemap
12
+ # Default minimum priority threshold (0.0 to 1.0)
13
+ DEFAULT_MIN_PRIORITY = 0.3
14
+ # Default maximum age in days for sitemap entries
15
+ DEFAULT_MAX_AGE_DAYS = 30
16
+ # Google News sitemap XML namespace
17
+ NEWS_NS = 'http://www.google.com/schemas/sitemap-news/0.9'
18
+
19
+ # Result value object returned by .call
20
+ # @!attribute entries [Array<SitemapEntry>] parsed entries (empty for sitemapindex docs)
21
+ # @!attribute sub_sitemap_urls [Array<String>] child sitemap URLs (empty for urlset docs)
22
+ Result = Data.define(:entries, :sub_sitemap_urls)
23
+
24
+ # Immutable facts for one sitemap entry
25
+ SitemapEntry = Data.define(:url, :title, :published_at, :priority, :changefreq)
26
+
27
+ class << self
28
+ ##
29
+ # Parses sitemap XML body and returns a Result.
30
+ #
31
+ # @param xml_content [String] sitemap XML body string
32
+ # @param min_priority [Float] minimum priority score (0.0..1.0)
33
+ # @param max_age_days [Integer] maximum age in days for entries
34
+ # @return [Result] result with entries and/or sub_sitemap_urls
35
+ def call(xml_content, min_priority: DEFAULT_MIN_PRIORITY, max_age_days: DEFAULT_MAX_AGE_DAYS)
36
+ new(xml_content, min_priority:, max_age_days:).call
37
+ end
38
+ end
39
+
40
+ ##
41
+ # @param xml_content [String]
42
+ # @param min_priority [Float]
43
+ # @param max_age_days [Integer]
44
+ def initialize(xml_content, min_priority: DEFAULT_MIN_PRIORITY, max_age_days: DEFAULT_MAX_AGE_DAYS)
45
+ @xml_content = xml_content
46
+ @min_priority = min_priority
47
+ @max_age_days = max_age_days
48
+ end
49
+
50
+ ##
51
+ # @return [Result]
52
+ def call
53
+ document = Nokogiri::XML(xml_content)
54
+ return Result.new(entries: [], sub_sitemap_urls: []) if invalid_xml?(document)
55
+
56
+ if sitemapindex?(document)
57
+ Result.new(entries: [], sub_sitemap_urls: extract_sub_sitemap_urls(document))
58
+ else
59
+ Result.new(entries: extract_entries(document), sub_sitemap_urls: [])
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :xml_content, :min_priority, :max_age_days
66
+
67
+ def invalid_xml?(document)
68
+ document.errors.any? && document.at_css('urlset, sitemapindex').nil?
69
+ end
70
+
71
+ def sitemapindex?(document)
72
+ !document.at_xpath('//*[local-name()="sitemapindex"]').nil?
73
+ end
74
+
75
+ def extract_sub_sitemap_urls(document)
76
+ document.xpath('//*[local-name()="sitemap"]/*[local-name()="loc"]').filter_map do |node|
77
+ text = node.text.strip
78
+ text unless text.empty?
79
+ end
80
+ end
81
+
82
+ def extract_entries(document)
83
+ document.xpath('//*[local-name()="url"]').filter_map { build_entry(_1) }
84
+ end
85
+
86
+ def build_entry(url_node)
87
+ loc = parse_loc(url_node)
88
+ return unless loc
89
+ return if invalid_entry_signals?(url_node)
90
+
91
+ published_at = parse_date(url_node)
92
+ return if stale_date?(published_at)
93
+
94
+ build_entry_facts(url_node, loc, published_at)
95
+ end
96
+
97
+ def build_entry_facts(url_node, loc, published_at)
98
+ SitemapEntry.new(
99
+ url: loc,
100
+ title: parse_title(url_node),
101
+ published_at:,
102
+ priority: parse_priority(url_node),
103
+ changefreq: parse_changefreq(url_node)
104
+ )
105
+ end
106
+
107
+ def parse_loc(url_node)
108
+ node = url_node.at_xpath('./*[local-name()="loc"]')
109
+ text = node&.text&.strip
110
+ text unless text.to_s.empty?
111
+ end
112
+
113
+ def parse_changefreq(url_node)
114
+ node = url_node.at_xpath('./*[local-name()="changefreq"]')
115
+ node ? node.text.strip.downcase : nil
116
+ end
117
+
118
+ def invalid_entry_signals?(url_node)
119
+ parse_priority(url_node) < min_priority || parse_changefreq(url_node) == 'never'
120
+ end
121
+
122
+ def parse_priority(url_node)
123
+ node = url_node.at_xpath('./*[local-name()="priority"]')
124
+ text = node&.text&.strip
125
+ text ? text.to_f : 0.5
126
+ end
127
+
128
+ def parse_date(url_node)
129
+ raw_date = raw_date_from(url_node)
130
+ return unless raw_date
131
+
132
+ Time.parse(raw_date).utc.iso8601
133
+ rescue ArgumentError
134
+ nil
135
+ end
136
+
137
+ def raw_date_from(url_node)
138
+ news_node = url_node.at_xpath('.//news:publication_date', 'news' => NEWS_NS)
139
+ lastmod_node = url_node.at_xpath('./*[local-name()="lastmod"]')
140
+ (news_node || lastmod_node)&.text&.strip
141
+ end
142
+
143
+ def parse_title(url_node)
144
+ node = url_node.at_xpath('.//news:title', 'news' => NEWS_NS)
145
+ node&.text&.strip
146
+ end
147
+
148
+ def stale_date?(published_at)
149
+ return false unless published_at
150
+
151
+ time = Time.parse(published_at)
152
+ (Time.now.utc - time) > (max_age_days * 86_400)
153
+ rescue ArgumentError
154
+ false
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ ##
6
+ # Card/list discovery helpers used by AutoSource scrapers before field extraction.
7
+ #
8
+ # Html2rss::Html::ArticleExtractor fills article fields from a container; discovery finds those containers.
9
+ #
10
+ # DOM list discovery for anchorless or classless pages is owned by {Discovery::DomClustering}.
11
+ module Discovery
12
+ end
13
+ end
14
+ end