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.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- 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
|