html2rss 0.22.2 → 0.23.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 +206 -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/thing.rb +21 -2
- 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 +118 -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/json_feed/item.rb +96 -0
- data/lib/html2rss/feed_builder/json_feed.rb +61 -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 +23 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
- 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 +52 -65
- 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 +0 -4
- data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +90 -16
- 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_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +102 -6
- 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 +25 -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 +63 -65
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +19 -19
- data/schema/html2rss-config.schema.json +109 -9
- metadata +68 -42
- 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/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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
##
|
|
6
|
+
# Shared link eligibility and scoring policy for AutoSource scrapers.
|
|
7
|
+
#
|
|
8
|
+
# Scrapers collect DOM observations; this module owns junk/noise rules and
|
|
9
|
+
# numeric weights so eligibility policy stays in one place.
|
|
10
|
+
class LinkHeuristics
|
|
11
|
+
# @param base_url [String, Html2rss::Url] page URL used to resolve relative hrefs
|
|
12
|
+
def initialize(base_url)
|
|
13
|
+
@base_url = base_url
|
|
14
|
+
@text_classifier = TextClassifier.new
|
|
15
|
+
@container_assessor = ContainerAssessor.new(text_classifier: @text_classifier)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Builds normalized destination facts for an anchor element or href string.
|
|
19
|
+
#
|
|
20
|
+
# @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
|
|
21
|
+
# @return [DestinationFacts, nil] normalized destination facts, or nil for blank/invalid URLs
|
|
22
|
+
def destination_facts(anchor_or_href)
|
|
23
|
+
return node_facts[anchor_or_href] if node_facts.key?(anchor_or_href)
|
|
24
|
+
|
|
25
|
+
href = HrefExtractor.call(anchor_or_href)
|
|
26
|
+
return unless href
|
|
27
|
+
|
|
28
|
+
res = memoized_destination_facts(href)
|
|
29
|
+
|
|
30
|
+
node_facts[anchor_or_href] = res if anchor_or_href.is_a?(Nokogiri::XML::Node)
|
|
31
|
+
res
|
|
32
|
+
rescue ArgumentError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param text [String, #to_s] visible anchor text
|
|
37
|
+
# @return [Boolean] true when text matches a utility label
|
|
38
|
+
def utility_text?(text) = @text_classifier.utility?(text)
|
|
39
|
+
|
|
40
|
+
# @param text [String, #to_s] visible anchor text
|
|
41
|
+
# @return [Boolean] true when text begins with a utility label
|
|
42
|
+
def utility_prefix_text?(text) = @text_classifier.utility_prefix?(text)
|
|
43
|
+
|
|
44
|
+
# @param text [String, #to_s] visible anchor text
|
|
45
|
+
# @return [Boolean] true when text identifies recommendation chrome
|
|
46
|
+
def recommended_text?(text) = @text_classifier.recommended?(text)
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Whether an anchor is junk chrome rather than a content permalink.
|
|
50
|
+
#
|
|
51
|
+
# One eligibility home for Html and SemanticHtml: taxonomy/utility text
|
|
52
|
+
# rules plus optional DOM checks (icon-only, utility landmarks).
|
|
53
|
+
#
|
|
54
|
+
# @param text [String, #to_s] visible anchor text
|
|
55
|
+
# @param destination_facts [DestinationFacts, nil] route facts for the href
|
|
56
|
+
# @param anchor [Nokogiri::XML::Node, nil] anchor node for icon/landmark checks
|
|
57
|
+
# @param container [Nokogiri::XML::Node, nil] content container bounding landmark walks
|
|
58
|
+
# @param heading_anchor [Boolean] whether the anchor is the container heading link
|
|
59
|
+
# @return [Boolean] true when the anchor should be ignored
|
|
60
|
+
def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil, heading_anchor: false) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
61
|
+
return true unless destination_facts
|
|
62
|
+
|
|
63
|
+
destination_facts.taxonomy_path ||
|
|
64
|
+
short_utility_label?(text, destination_facts) ||
|
|
65
|
+
recommended_chrome?(text, destination_facts, heading_anchor:) ||
|
|
66
|
+
(utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
|
|
67
|
+
(utility_text?(text) && destination_facts.vanity_path) ||
|
|
68
|
+
utility_text_chrome?(text, destination_facts, heading_anchor:) ||
|
|
69
|
+
icon_only_anchor?(anchor, text) ||
|
|
70
|
+
utility_landmark_ancestor?(anchor, container)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
##
|
|
74
|
+
# Observes a container and builds ranking signals, including hard-junk.
|
|
75
|
+
#
|
|
76
|
+
# Delegates DOM observation to ContainerAssessor so SemanticHtml only
|
|
77
|
+
# orchestrates candidates and extraction, while ContainerSignals keeps
|
|
78
|
+
# scoring policy.
|
|
79
|
+
#
|
|
80
|
+
# @param container [Nokogiri::XML::Node] semantic container node
|
|
81
|
+
# @param selected_anchor [Nokogiri::XML::Node, nil] primary anchor for the container
|
|
82
|
+
# @param destination_facts [DestinationFacts, nil] route facts for the selected anchor
|
|
83
|
+
# @return [ContainerSignals] observation + scoring signals for the container
|
|
84
|
+
def assess_container(container, selected_anchor, destination_facts:)
|
|
85
|
+
@container_assessor.call(container, selected_anchor, destination_facts:)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def short_utility_label?(text, destination_facts)
|
|
91
|
+
destination_facts.utility_path &&
|
|
92
|
+
!destination_facts.content_path &&
|
|
93
|
+
!destination_facts.strong_post_suffix &&
|
|
94
|
+
text.to_s.scan(/\p{Alnum}+/).size <= 3
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def recommended_chrome?(text, destination_facts, heading_anchor:)
|
|
98
|
+
# Heading-linked titles may start with "Recommended …" while still being
|
|
99
|
+
# real posts; container hard_junk? owns that call with publish markers.
|
|
100
|
+
!heading_anchor && recommended_text?(text) && destination_facts.shallow
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def utility_text_chrome?(text, destination_facts, heading_anchor:)
|
|
104
|
+
return false if destination_facts.content_path
|
|
105
|
+
return false unless utility_text?(text)
|
|
106
|
+
|
|
107
|
+
!heading_anchor && !destination_facts.strong_post_suffix
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def icon_only_anchor?(anchor, text)
|
|
111
|
+
return false unless anchor
|
|
112
|
+
|
|
113
|
+
!text.to_s.match?(/\p{Alnum}/) && !anchor.at_css('img, svg').nil?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def utility_landmark_ancestor?(anchor, container)
|
|
117
|
+
return false unless anchor && container
|
|
118
|
+
|
|
119
|
+
condition = lambda { |node|
|
|
120
|
+
node == container || Html2rss::Html::Navigator::UTILITY_LANDMARK_TAGS.include?(node.name)
|
|
121
|
+
}
|
|
122
|
+
landmark = Html2rss::Html::Navigator.parent_until_condition(anchor.parent, condition)
|
|
123
|
+
|
|
124
|
+
!landmark.nil? && landmark != container
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def node_facts
|
|
128
|
+
@node_facts ||= {}.compare_by_identity
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def memoized_destination_facts(href)
|
|
132
|
+
(@destination_facts ||= {})[href] ||= begin
|
|
133
|
+
url = Html2rss::Url.from_relative(href, @base_url)
|
|
134
|
+
DestinationFacts.build(url)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -48,7 +48,7 @@ module Html2rss
|
|
|
48
48
|
# @param xpath [String] original XPath
|
|
49
49
|
# @return [String] XPath without positional indexes
|
|
50
50
|
def self.simplify_xpath(xpath)
|
|
51
|
-
|
|
51
|
+
Discovery::ListCandidates.simplify_xpath(xpath)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
|
|
@@ -57,7 +57,7 @@ module Html2rss
|
|
|
57
57
|
# @param opts [Hash] Additional options.
|
|
58
58
|
# @option opts [Integer] :minimum_selector_frequency minimum count before a selector is considered stable
|
|
59
59
|
# @option opts [Integer] :use_top_selectors number of top selectors to keep
|
|
60
|
-
def initialize(parsed_body, url:, extractor:
|
|
60
|
+
def initialize(parsed_body, url:, extractor: Html2rss::Html::ArticleExtractor, **opts)
|
|
61
61
|
@parsed_body = parsed_body
|
|
62
62
|
@url = url
|
|
63
63
|
@extractor = extractor
|
|
@@ -96,7 +96,7 @@ module Html2rss
|
|
|
96
96
|
# @return [Boolean] true when the node is a good extraction boundary
|
|
97
97
|
def article_tag_condition?(node)
|
|
98
98
|
# Ignore tags that are below ignored DOM chrome.
|
|
99
|
-
return false if
|
|
99
|
+
return false if Html2rss::Html::Navigator.ignored_container_path?(node, @ignored_cache)
|
|
100
100
|
return true if %w[body html].include?(node.name)
|
|
101
101
|
return false unless (parent = node.parent)
|
|
102
102
|
|
|
@@ -117,7 +117,10 @@ module Html2rss
|
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
def find_anchorless_articles
|
|
120
|
-
|
|
120
|
+
Discovery::DomClustering.call(
|
|
121
|
+
parsed_body,
|
|
122
|
+
minimum_selector_frequency:
|
|
123
|
+
).map do |node|
|
|
121
124
|
@extractor.new(node, base_url: @url, selected_anchor: nil, fallback_anchorless: true).call
|
|
122
125
|
end
|
|
123
126
|
end
|
|
@@ -144,7 +147,7 @@ module Html2rss
|
|
|
144
147
|
destination_facts = @link_heuristics.destination_facts(node)
|
|
145
148
|
return false unless destination_facts
|
|
146
149
|
|
|
147
|
-
!noise_anchor?(node, destination_facts)
|
|
150
|
+
!noise_anchor?(node, destination_facts:)
|
|
148
151
|
end
|
|
149
152
|
|
|
150
153
|
##
|
|
@@ -168,52 +171,38 @@ module Html2rss
|
|
|
168
171
|
def extract_article(article_tag, selected_anchor: nil)
|
|
169
172
|
selected_anchor ||= preferred_anchor_for(article_tag)
|
|
170
173
|
return unless selected_anchor
|
|
171
|
-
return if noise_anchor?(
|
|
174
|
+
return if noise_anchor?(
|
|
175
|
+
selected_anchor,
|
|
176
|
+
destination_facts: @link_heuristics.destination_facts(selected_anchor),
|
|
177
|
+
container: article_tag
|
|
178
|
+
)
|
|
172
179
|
|
|
173
|
-
@extractor.
|
|
180
|
+
@extractor.call(article_tag, base_url: @url, selected_anchor:)
|
|
174
181
|
end
|
|
175
182
|
|
|
176
183
|
##
|
|
177
184
|
# @param anchor [Nokogiri::XML::Node]
|
|
178
|
-
# @param destination_facts [DestinationFacts]
|
|
185
|
+
# @param destination_facts [LinkHeuristics::DestinationFacts, nil]
|
|
179
186
|
# @return [Boolean]
|
|
180
|
-
def noise_anchor?(anchor, destination_facts
|
|
181
|
-
return true unless destination_facts
|
|
182
|
-
|
|
187
|
+
def noise_anchor?(anchor, destination_facts:, container: nil)
|
|
183
188
|
(@noise_anchors ||= {}.compare_by_identity)[anchor] ||= begin
|
|
184
|
-
text =
|
|
185
|
-
|
|
186
|
-
destination_facts.taxonomy_path ||
|
|
187
|
-
short_utility_label?(text, destination_facts) ||
|
|
188
|
-
(@link_heuristics.recommended_text?(text) && destination_facts.shallow) ||
|
|
189
|
-
(@link_heuristics.utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
|
|
190
|
-
(@link_heuristics.utility_text?(text) && destination_facts.vanity_path)
|
|
189
|
+
text = Html2rss::Html::Navigator.extract_visible_text(anchor).to_s.strip
|
|
190
|
+
@link_heuristics.noise_anchor?(text:, destination_facts:, anchor:, container:)
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
-
##
|
|
195
|
-
# @param text [String]
|
|
196
|
-
# @param destination_facts [DestinationFacts]
|
|
197
|
-
# @return [Boolean]
|
|
198
|
-
def short_utility_label?(text, destination_facts)
|
|
199
|
-
destination_facts.utility_path &&
|
|
200
|
-
!destination_facts.content_path &&
|
|
201
|
-
!destination_facts.strong_post_suffix &&
|
|
202
|
-
text.scan(/\p{Alnum}+/).size <= 3
|
|
203
|
-
end
|
|
204
|
-
|
|
205
194
|
##
|
|
206
195
|
# @param article_tag [Nokogiri::XML::Node]
|
|
207
196
|
# @return [Nokogiri::XML::Node, nil]
|
|
208
197
|
def preferred_anchor_for(article_tag)
|
|
209
|
-
article_tag.css(
|
|
210
|
-
|
|
198
|
+
article_tag.css(Html2rss::Html::Navigator::MAIN_ANCHOR_SELECTOR).find { relevant_anchor?(_1) } ||
|
|
199
|
+
Html2rss::Html::Navigator.main_anchor_for(article_tag)
|
|
211
200
|
end
|
|
212
201
|
|
|
213
202
|
##
|
|
214
|
-
# @return [
|
|
203
|
+
# @return [Discovery::ListCandidates]
|
|
215
204
|
def list_candidates
|
|
216
|
-
|
|
205
|
+
Discovery::ListCandidates.new(
|
|
217
206
|
parsed_body,
|
|
218
207
|
minimum_selector_frequency:,
|
|
219
208
|
use_top_selectors:
|
|
@@ -9,7 +9,7 @@ module Html2rss
|
|
|
9
9
|
# Scrapes JSON state blobs embedded in script tags such as Next.js, Nuxt,
|
|
10
10
|
# or custom window globals. The scraper searches `<script type="application/json">`
|
|
11
11
|
# tags and well-known JavaScript globals for arrays of article-like hashes
|
|
12
|
-
# and normalises them to a structure compatible with
|
|
12
|
+
# and normalises them to a structure compatible with Html2rss::Html::ArticleExtractor.
|
|
13
13
|
class JsonState
|
|
14
14
|
include Enumerable
|
|
15
15
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
|
|
6
|
+
module Html2rss
|
|
7
|
+
class AutoSource
|
|
8
|
+
module Scraper
|
|
9
|
+
##
|
|
10
|
+
# Scrapes OpenGraph meta tags and oEmbed JSON descriptors from HTML pages.
|
|
11
|
+
class MetaOembed
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
# Selector for OpenGraph meta tags.
|
|
15
|
+
OG_META_SELECTOR = 'meta[property^="og:"], meta[property^="article:"], meta[name^="twitter:"]'
|
|
16
|
+
# Selector for oEmbed JSON link tag.
|
|
17
|
+
OEMBED_LINK_SELECTOR = 'link[rel="alternate"][type="application/json+oembed"][href]'
|
|
18
|
+
|
|
19
|
+
# Mapping of meta property names to article attribute keys.
|
|
20
|
+
META_MAP = {
|
|
21
|
+
'og:title' => :title,
|
|
22
|
+
'twitter:title' => :title,
|
|
23
|
+
'og:url' => :url,
|
|
24
|
+
'og:description' => :description,
|
|
25
|
+
'twitter:description' => :description,
|
|
26
|
+
'og:image' => :image,
|
|
27
|
+
'twitter:image' => :image,
|
|
28
|
+
'article:published_time' => :published_at,
|
|
29
|
+
'article:author' => :author
|
|
30
|
+
}.freeze
|
|
31
|
+
private_constant :META_MAP
|
|
32
|
+
|
|
33
|
+
# @return [Symbol] scraper config key
|
|
34
|
+
def self.options_key = :meta_oembed
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
38
|
+
# @return [Boolean] whether OpenGraph tags or oEmbed link exist
|
|
39
|
+
def articles?(parsed_body)
|
|
40
|
+
return false unless parsed_body
|
|
41
|
+
|
|
42
|
+
!parsed_body.at_css('meta[property="og:title"]').nil? ||
|
|
43
|
+
!parsed_body.at_css(OEMBED_LINK_SELECTOR).nil?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
48
|
+
# @param url [String, Html2rss::Url] base page URL
|
|
49
|
+
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
50
|
+
# @param _opts [Hash] unused scraper-specific options
|
|
51
|
+
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
52
|
+
# @return [void]
|
|
53
|
+
def initialize(parsed_body, url:, request_session: nil, **_opts)
|
|
54
|
+
@parsed_body = parsed_body
|
|
55
|
+
@url = Html2rss::Url.from_absolute(url)
|
|
56
|
+
@request_session = request_session
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# Yields normalized article hash extracted from OpenGraph meta tags and optional oEmbed data.
|
|
61
|
+
#
|
|
62
|
+
# @yieldparam article [Hash{Symbol => Object}] normalized article hash
|
|
63
|
+
# @return [Enumerator, void] enumerator when no block is given
|
|
64
|
+
def each
|
|
65
|
+
return enum_for(:each) unless block_given?
|
|
66
|
+
|
|
67
|
+
article = build_article
|
|
68
|
+
yield article if article
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
attr_reader :parsed_body, :url, :request_session
|
|
74
|
+
|
|
75
|
+
# @return [Hash{Symbol => Object}, nil] assembled article hash or nil
|
|
76
|
+
def build_article
|
|
77
|
+
meta_data = extract_meta_data
|
|
78
|
+
oembed_data = fetch_oembed_data
|
|
79
|
+
|
|
80
|
+
article_url = resolve_url(meta_data[:url]) || url
|
|
81
|
+
title = oembed_data[:title] || meta_data[:title]
|
|
82
|
+
|
|
83
|
+
return unless title
|
|
84
|
+
|
|
85
|
+
assemble_article(meta_data, oembed_data, article_url, title)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Hash{Symbol => Object}] extracted metadata hash
|
|
89
|
+
def extract_meta_data
|
|
90
|
+
{}.tap do |data|
|
|
91
|
+
parsed_body.css(OG_META_SELECTOR).each do |meta|
|
|
92
|
+
prop = meta['property'] || meta['name']
|
|
93
|
+
content = meta['content']
|
|
94
|
+
next if prop.nil? || content.nil? || content.empty?
|
|
95
|
+
|
|
96
|
+
assign_meta_prop(data, prop, content)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param data [Hash{Symbol => Object}] target metadata accumulator
|
|
102
|
+
# @param prop [String] meta tag property/name
|
|
103
|
+
# @param content [String] meta tag content
|
|
104
|
+
# @return [void]
|
|
105
|
+
def assign_meta_prop(data, prop, content)
|
|
106
|
+
key = META_MAP[prop]
|
|
107
|
+
data[key] ||= content if key
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Hash{Symbol => Object}] oEmbed fields hash
|
|
111
|
+
def fetch_oembed_data
|
|
112
|
+
return {} unless request_session && (link_node = parsed_body.at_css(OEMBED_LINK_SELECTOR))
|
|
113
|
+
return {} unless (oembed_url = resolve_url(link_node['href']))
|
|
114
|
+
|
|
115
|
+
response = request_session.follow_up(url: oembed_url, relation: :auto_source, origin_url: url)
|
|
116
|
+
parse_oembed_response(response)
|
|
117
|
+
rescue Html2rss::Error, RequestService::UnsupportedResponseContentType, JSON::ParserError => error
|
|
118
|
+
Log.warn("#{self.class}: failed to fetch oEmbed payload (#{error.class}: #{error.message})")
|
|
119
|
+
{}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param response [Html2rss::RequestService::Response, nil] HTTP response
|
|
123
|
+
# @return [Hash{Symbol => Object}] parsed oEmbed fields
|
|
124
|
+
def parse_oembed_response(response)
|
|
125
|
+
return {} unless response
|
|
126
|
+
|
|
127
|
+
payload = response.parsed_body
|
|
128
|
+
return {} unless payload.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
thumbnail = payload['thumbnail_url'] || (payload['type'] == 'photo' ? payload['url'] : nil)
|
|
131
|
+
|
|
132
|
+
{ title: payload['title'], author: payload['author_name'], thumbnail:, html: payload['html'] }.compact
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @param meta_data [Hash{Symbol => Object}] extracted OpenGraph meta tags
|
|
136
|
+
# @param oembed_data [Hash{Symbol => Object}] parsed oEmbed fields
|
|
137
|
+
# @param article_url [Html2rss::Url] resolved article URL
|
|
138
|
+
# @param title [String] resolved article title
|
|
139
|
+
# @return [Hash{Symbol => Object}] final article hash
|
|
140
|
+
def assemble_article(meta_data, oembed_data, article_url, title)
|
|
141
|
+
{
|
|
142
|
+
url: article_url,
|
|
143
|
+
title:,
|
|
144
|
+
description: meta_data[:description] || oembed_data[:html],
|
|
145
|
+
author: oembed_data[:author] || meta_data[:author],
|
|
146
|
+
published_at: meta_data[:published_at],
|
|
147
|
+
image: oembed_data[:thumbnail] || meta_data[:image]
|
|
148
|
+
}.compact
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @param raw_url [String, nil] raw relative or absolute URL string
|
|
152
|
+
# @return [Html2rss::Url, nil] resolved absolute URL or nil
|
|
153
|
+
def resolve_url(raw_url)
|
|
154
|
+
return if raw_url.nil? || raw_url.empty?
|
|
155
|
+
|
|
156
|
+
Html2rss::Url.from_relative(raw_url, url)
|
|
157
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class AutoSource
|
|
7
|
+
module Scraper
|
|
8
|
+
##
|
|
9
|
+
# Scrapes h-entry objects based on Microformats2 specifications.
|
|
10
|
+
#
|
|
11
|
+
# @see https://microformats.org/wiki/h-entry
|
|
12
|
+
class Microformats2
|
|
13
|
+
include Enumerable
|
|
14
|
+
|
|
15
|
+
# Selector for Microformats2 h-entry containers.
|
|
16
|
+
ENTRY_SELECTOR = '.h-entry, [class*="h-entry"]'
|
|
17
|
+
|
|
18
|
+
# @return [Symbol] scraper config key
|
|
19
|
+
def self.options_key = :microformats2
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
23
|
+
# @return [Boolean] whether Microformats2 h-entry elements exist
|
|
24
|
+
def articles?(parsed_body)
|
|
25
|
+
return false unless parsed_body
|
|
26
|
+
|
|
27
|
+
!parsed_body.at_css(ENTRY_SELECTOR).nil?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
32
|
+
# @param url [String, Html2rss::Url] base page URL
|
|
33
|
+
# @param _opts [Hash] unused scraper-specific options
|
|
34
|
+
# @option _opts [Object] :_reserved reserved for future options
|
|
35
|
+
# @return [void]
|
|
36
|
+
def initialize(parsed_body, url:, **_opts)
|
|
37
|
+
@parsed_body = parsed_body
|
|
38
|
+
@url = Html2rss::Url.from_absolute(url)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# Yields normalized article hashes extracted from h-entry elements.
|
|
43
|
+
#
|
|
44
|
+
# @yieldparam article [Hash{Symbol => Object}] normalized article hash
|
|
45
|
+
# @return [Enumerator, void] enumerator when no block is given
|
|
46
|
+
def each
|
|
47
|
+
return enum_for(:each) unless block_given?
|
|
48
|
+
|
|
49
|
+
parsed_body.css(ENTRY_SELECTOR).each do |entry|
|
|
50
|
+
article = build_article(entry)
|
|
51
|
+
yield article if article
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
attr_reader :parsed_body, :url
|
|
58
|
+
|
|
59
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
60
|
+
# @return [Hash{Symbol => Object}, nil] article hash or nil
|
|
61
|
+
def build_article(entry)
|
|
62
|
+
title = extract_title(entry)
|
|
63
|
+
article_url = extract_url(entry)
|
|
64
|
+
return unless title || article_url
|
|
65
|
+
|
|
66
|
+
assemble_article(entry, title, article_url)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
70
|
+
# @return [String, nil] title text
|
|
71
|
+
def extract_title(entry)
|
|
72
|
+
node = entry.at_css('.p-name')
|
|
73
|
+
text = node&.text&.strip
|
|
74
|
+
text unless text.to_s.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
78
|
+
# @return [Html2rss::Url, nil] resolved article URL
|
|
79
|
+
def extract_url(entry)
|
|
80
|
+
node = entry.at_css('.u-url')
|
|
81
|
+
raw_url = node&.attr('href')&.strip
|
|
82
|
+
resolve_url(raw_url)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
86
|
+
# @param title [String, nil] article title
|
|
87
|
+
# @param article_url [Html2rss::Url, nil] resolved article URL
|
|
88
|
+
# @return [Hash{Symbol => Object}] assembled article hash
|
|
89
|
+
def assemble_article(entry, title, article_url)
|
|
90
|
+
{ title:, url: article_url || url, description: extract_description(entry),
|
|
91
|
+
image: extract_image(entry), author: extract_author(entry),
|
|
92
|
+
published_at: extract_published_at(entry), categories: extract_categories(entry) }.compact
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
96
|
+
# @return [String, nil] description text or html
|
|
97
|
+
def extract_description(entry)
|
|
98
|
+
node = entry.at_css('.e-content, .p-summary')
|
|
99
|
+
content = node&.inner_html&.strip || node&.text&.strip
|
|
100
|
+
content unless content.to_s.empty?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
104
|
+
# @return [String, nil] image URL
|
|
105
|
+
def extract_image(entry)
|
|
106
|
+
node = entry.at_css('.u-featured, .u-photo')
|
|
107
|
+
raw_src = node&.attr('src')&.strip || node&.attr('href')&.strip
|
|
108
|
+
resolve_url(raw_src)&.to_s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
112
|
+
# @return [String, nil] author text
|
|
113
|
+
def extract_author(entry)
|
|
114
|
+
node = entry.at_css('.p-author .p-name, .p-author, .h-card .p-name')
|
|
115
|
+
text = node&.text&.strip
|
|
116
|
+
text unless text.to_s.empty?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
120
|
+
# @return [String, nil] published timestamp
|
|
121
|
+
def extract_published_at(entry)
|
|
122
|
+
node = entry.at_css('.dt-published')
|
|
123
|
+
timestamp = node&.attr('datetime')&.strip || node&.text&.strip
|
|
124
|
+
timestamp unless timestamp.to_s.empty?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
128
|
+
# @return [Array<String>, nil] categories array
|
|
129
|
+
def extract_categories(entry)
|
|
130
|
+
categories = entry.css('.p-category').map { _1.text.strip }.reject(&:empty?)
|
|
131
|
+
categories unless categories.empty?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param raw_url [String, nil] raw URL string
|
|
135
|
+
# @return [Html2rss::Url, nil] resolved absolute URL or nil
|
|
136
|
+
def resolve_url(raw_url)
|
|
137
|
+
return if raw_url.to_s.empty?
|
|
138
|
+
|
|
139
|
+
Html2rss::Url.from_relative(raw_url, url)
|
|
140
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -20,7 +20,7 @@ module Html2rss
|
|
|
20
20
|
].to_set.freeze
|
|
21
21
|
|
|
22
22
|
# Attributes exposed by `#call` in generated article hashes.
|
|
23
|
-
DEFAULT_ATTRIBUTES = %i[id title description url image published_at categories].freeze
|
|
23
|
+
DEFAULT_ATTRIBUTES = %i[id title description author url image published_at categories].freeze
|
|
24
24
|
|
|
25
25
|
# @param schema_object [Hash{Symbol => Object}] parsed schema.org object
|
|
26
26
|
# @param url [String, Html2rss::Url, nil] base URL used for relative normalization
|
|
@@ -45,7 +45,12 @@ module Html2rss
|
|
|
45
45
|
|
|
46
46
|
# @return [String, nil] longest available description field
|
|
47
47
|
def description
|
|
48
|
-
schema_object.values_at(:description, :
|
|
48
|
+
schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body).max_by { _1.to_s.size }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String, nil] extracted author or publisher name
|
|
52
|
+
def author
|
|
53
|
+
extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher])
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
# @return [Html2rss::Url, nil] the URL of the schema object
|
|
@@ -86,6 +91,20 @@ module Html2rss
|
|
|
86
91
|
|
|
87
92
|
private
|
|
88
93
|
|
|
94
|
+
# @param obj [String, Hash, Array, nil] schema author or publisher field
|
|
95
|
+
# @return [String, nil] extracted author name
|
|
96
|
+
def extract_author_name(obj)
|
|
97
|
+
case obj
|
|
98
|
+
when String then obj.strip unless obj.strip.empty?
|
|
99
|
+
when Hash then obj[:name].to_s.strip unless obj[:name].to_s.strip.empty?
|
|
100
|
+
when Array
|
|
101
|
+
names = obj.filter_map { extract_author_name(_1) }
|
|
102
|
+
names.join(', ') unless names.empty?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param obj [String, Hash, nil] image node
|
|
107
|
+
# @return [String, nil] image URL string
|
|
89
108
|
def image_url_from(obj)
|
|
90
109
|
return obj if obj.is_a?(String)
|
|
91
110
|
return unless obj.is_a?(Hash) && obj[:@type] == 'ImageObject'
|
data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb}
RENAMED
|
@@ -7,7 +7,7 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# Collapses nested containers and deduplicates entries pointing to the same destination.
|
|
9
9
|
# It resolves ties using scoring precedence and payload richness comparison.
|
|
10
|
-
class
|
|
10
|
+
class EntryDeduplicator
|
|
11
11
|
# @param url [String, Html2rss::Url] base url used to resolve relative hrefs
|
|
12
12
|
# @param extractor [Class] extractor class used to materialize articles
|
|
13
13
|
def initialize(url, extractor)
|
|
@@ -36,9 +36,9 @@ module Html2rss
|
|
|
36
36
|
return entry.article if entry.article
|
|
37
37
|
|
|
38
38
|
@article_cache.fetch(entry) do
|
|
39
|
-
@article_cache[entry] = @extractor.
|
|
39
|
+
@article_cache[entry] = @extractor.call(
|
|
40
40
|
entry.container, base_url: @url, selected_anchor: entry.selected_anchor, fallback_anchorless: true
|
|
41
|
-
)
|
|
41
|
+
)
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|