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,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
- HtmlExtractor::ListCandidates.simplify_xpath(xpath)
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: HtmlExtractor, **opts)
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 HtmlExtractor.ignored_container_path?(node, @ignored_cache)
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
- ClassClustering.call(parsed_body, minimum_selector_frequency:).map do |node|
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?(selected_anchor, @link_heuristics.destination_facts(selected_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.new(article_tag, base_url: @url, selected_anchor:).call
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) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
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 = HtmlExtractor.extract_visible_text(anchor).to_s.strip
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(HtmlExtractor::MAIN_ANCHOR_SELECTOR).find { relevant_anchor?(_1) } ||
210
- HtmlExtractor.main_anchor_for(article_tag)
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 [HtmlExtractor::ListCandidates]
203
+ # @return [Discovery::ListCandidates]
215
204
  def list_candidates
216
- HtmlExtractor::ListCandidates.new(
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 HtmlExtractor.
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