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
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'semantic_html/anchor_selector'
4
- require_relative 'semantic_html/deduplicator'
5
-
6
3
  module Html2rss
7
4
  class AutoSource
8
5
  module Scraper
@@ -18,21 +15,9 @@ module Html2rss
18
15
  # The result is lower recall on weak-signal blocks, but much better link
19
16
  # quality on modern teaser cards that mix headlines, utility links, and
20
17
  # duplicate image overlays.
21
- class SemanticHtml # rubocop:disable Metrics/ClassLength
18
+ class SemanticHtml
22
19
  include Enumerable
23
20
 
24
- # Regexp to match content-related tokens.
25
- CONTENT_REGEXP = begin
26
- words = LinkHeuristics::PathClassifier::SEGMENT_SETS.fetch(:content)
27
- /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
28
- end.freeze
29
-
30
- # Regexp to match junk/utility-related tokens.
31
- JUNK_REGEXP = begin
32
- words = LinkHeuristics::PathClassifier::SEGMENT_SETS.fetch(:utility)
33
- /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
34
- end.freeze
35
-
36
21
  # Container plus selected anchor, scoring metadata, and extracted article.
37
22
  Entry = Data.define(
38
23
  :container,
@@ -61,14 +46,13 @@ module Html2rss
61
46
  # @param url [String, Html2rss::Url] base url
62
47
  # @param extractor [Class] extractor class used for article extraction
63
48
  # @param opts [Hash] scraper-specific options
64
- # @option opts [Boolean] :fallback_anchorless whether to extract anchorless blocks
65
- def initialize(parsed_body, url:, extractor: HtmlExtractor, **opts)
49
+ # @option opts [Boolean] :fallback_anchorless whether to keep containers without a primary anchor
50
+ def initialize(parsed_body, url:, extractor: Html2rss::Html::ArticleExtractor, **opts)
66
51
  @parsed_body = parsed_body
67
52
  @url = url
68
53
  @extractor = extractor
69
- @fallback_anchorless = opts.fetch(:fallback_anchorless, false)
54
+ @permit_unanchored = opts.fetch(:fallback_anchorless, false)
70
55
  @link_heuristics = LinkHeuristics.new(url)
71
- @anchor_selector = AnchorSelector.new(url)
72
56
  end
73
57
 
74
58
  attr_reader :parsed_body
@@ -98,48 +82,53 @@ module Html2rss
98
82
  extractable_entries.any?
99
83
  end
100
84
 
101
- protected
102
-
85
+ # @return [Array<Nokogiri::XML::Node>]
103
86
  def candidate_containers
104
87
  @candidate_containers ||= collect_candidate_containers
105
88
  end
106
89
 
90
+ # @param container [Nokogiri::XML::Node]
91
+ # @return [Nokogiri::XML::Node, nil]
107
92
  def primary_anchor_for(container)
108
- @anchor_selector.primary_anchor_for(container)
93
+ Discovery::SemanticAnchorCandidates.new(
94
+ container,
95
+ link_heuristics: @link_heuristics
96
+ ).to_a.max_by(&:score)&.anchor
109
97
  end
110
98
 
111
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
99
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
112
100
  def extractable_entries
113
101
  @extractable_entries ||= candidate_containers.filter_map do |container|
114
102
  selected_anchor = primary_anchor_for(container)
115
103
 
116
- next unless selected_anchor || @fallback_anchorless
104
+ next unless selected_anchor || @permit_unanchored
117
105
 
118
106
  destination_facts = selected_anchor ? normalized_destination(selected_anchor) : nil
119
107
  next if selected_anchor && !destination_facts
120
- next if hard_junk_entry?(container, selected_anchor, destination_facts)
108
+ # Cheap path-only reject before title/DOM hard-junk observations.
109
+ next if destination_facts&.high_confidence_junk_path
121
110
 
122
- quality = quality_score(container, selected_anchor, destination_facts)
123
- junk = junk_score(container, selected_anchor, destination_facts)
111
+ signals = @link_heuristics.assess_container(container, selected_anchor, destination_facts:)
112
+ next if signals.hard_junk?
124
113
 
125
114
  Entry.new(
126
115
  container:,
127
116
  selected_anchor:,
128
117
  destination_facts:,
129
- quality_score: quality,
130
- junk_score: junk,
131
- final_score: quality - junk,
118
+ quality_score: signals.quality_score,
119
+ junk_score: signals.junk_score,
120
+ final_score: signals.final_score,
132
121
  position: document_position(container),
133
122
  article: nil
134
123
  )
135
124
  end
136
125
  end
137
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
126
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
138
127
 
139
128
  # rubocop:disable Metrics/MethodLength
140
129
  def ranked_entries
141
130
  @ranked_entries ||= begin
142
- deduplicator = Deduplicator.new(@url, @extractor)
131
+ deduplicator = EntryDeduplicator.new(@url, @extractor)
143
132
  entries = deduplicator.call(extractable_entries)
144
133
  entries = stable_rank(entries)
145
134
 
@@ -163,7 +152,7 @@ module Html2rss
163
152
  # rubocop:enable Metrics/MethodLength
164
153
 
165
154
  def collect_candidate_containers
166
- HtmlExtractor::SemanticContainers.call(parsed_body)
155
+ Discovery::SemanticContainers.call(parsed_body)
167
156
  end
168
157
 
169
158
  private
@@ -172,147 +161,10 @@ module Html2rss
172
161
  (@document_positions ||= candidate_containers.each_with_index.to_h).fetch(container)
173
162
  end
174
163
 
175
- def quality_score(container, selected_anchor, destination_facts) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
176
- title = entry_title(container, selected_anchor)
177
- words = word_count(title)
178
- container_text = visible_text(container)
179
- score = 0
180
-
181
- score += 40 if words >= 3
182
- score += 15 if words >= 7
183
- score += 20 if destination_facts&.url&.path.to_s.length > 6
184
- score += 15 if destination_facts&.content_path
185
- score += 15 if publish_marker?(container)
186
- score += 10 if descriptive_context?(container_text, title)
187
- score += 10 if article_container?(container)
188
- score += 10 if content_tokens?(container_tokens(container))
189
- score
190
- end
191
-
192
- def junk_score(container, selected_anchor, destination_facts) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
193
- title = entry_title(container, selected_anchor)
194
- utility_text = @link_heuristics.utility_prefix_text?(title)
195
- recommended_text = @link_heuristics.recommended_text?(title)
196
- content_signal = destination_facts&.content_path
197
- no_content_signal = !content_signal
198
- non_content_utility_path =
199
- destination_facts&.utility_path &&
200
- no_content_signal &&
201
- !destination_facts&.strong_post_suffix
202
- publish_signal = publish_marker?(container)
203
- descriptive_signal = descriptive_context?(visible_text(container), title)
204
- weak_container = !publish_signal && !descriptive_signal
205
- score = 0
206
-
207
- score += 25 if non_content_utility_path
208
- score += 15 if utility_text && word_count(title) <= 6
209
- score += 10 if destination_facts&.shallow
210
- score += 10 if weak_container
211
- score += 10 if recommended_text && no_content_signal
212
- score += 5 if destination_facts&.high_confidence_junk_path
213
- score += 15 if junk_tokens?(container_tokens(container))
214
- score
215
- end
216
-
217
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
218
- def hard_junk_entry?(container, selected_anchor, destination_facts)
219
- title = entry_title(container, selected_anchor)
220
- publish_signal = publish_marker?(container)
221
- descriptive_signal = descriptive_context?(visible_text(container), title)
222
- content_signal = destination_facts&.content_path
223
- weak_article_candidate = article_signal_count(
224
- container,
225
- publish_signal:,
226
- descriptive_signal:,
227
- content_signal:
228
- ) < 2
229
-
230
- destination_facts&.high_confidence_junk_path ||
231
- (selected_anchor &&
232
- @link_heuristics.recommended_text?(title) &&
233
- destination_facts&.shallow &&
234
- weak_article_candidate) ||
235
- (selected_anchor && @link_heuristics.utility_prefix_text?(title) &&
236
- destination_facts&.high_confidence_utility_destination &&
237
- weak_article_candidate)
238
- end
239
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
240
-
241
- ##
242
- # @param container [Nokogiri::XML::Node]
243
- # @return [Boolean]
244
- def publish_marker?(container)
245
- (@publish_markers ||= {}.compare_by_identity)[container] ||=
246
- !!container.at_css('time, [datetime], [itemprop="datePublished"], [itemprop="dateModified"]')
247
- end
248
-
249
- ##
250
- # @param container [Nokogiri::XML::Node]
251
- # @param publish_signal [Boolean]
252
- # @param descriptive_signal [Boolean]
253
- # @param content_signal [Boolean]
254
- # @return [Integer]
255
- def article_signal_count(container, publish_signal:, descriptive_signal:, content_signal:)
256
- [article_container?(container), publish_signal, descriptive_signal, content_signal].count(&:itself)
257
- end
258
-
259
- ##
260
- # @param container [Nokogiri::XML::Node]
261
- # @return [Boolean]
262
- def article_container?(container) = container.name == 'article'
263
-
264
- def descriptive_context?(container_text, title)
265
- snippet = container_text.to_s.sub(/\A#{Regexp.escape(title.to_s)}/i, '')
266
- # Only check for existence of enough words if snippet is long enough to have them
267
- snippet.length > 30 && word_count(snippet) >= 8
268
- end
269
-
270
- ##
271
- # @param container [Nokogiri::XML::Node]
272
- # @return [Nokogiri::XML::Node, nil]
273
- def heading_for(container)
274
- (@headings ||= {}.compare_by_identity)[container] ||= container.at_css(AnchorSelector::HEADING_SELECTOR)
275
- end
276
-
277
164
  def normalized_destination(anchor)
278
165
  (@normalized_destinations ||= {}.compare_by_identity)[anchor] ||= @link_heuristics.destination_facts(anchor)
279
166
  end
280
167
 
281
- def visible_text(node)
282
- return '' unless node
283
-
284
- (@visible_texts ||= {}.compare_by_identity)[node] ||= HtmlExtractor.extract_visible_text(node).to_s.strip
285
- end
286
-
287
- ##
288
- # @param container [Nokogiri::XML::Node]
289
- # @param selected_anchor [Nokogiri::XML::Node]
290
- # @return [String]
291
- def entry_title(container, selected_anchor) = visible_text(heading_for(container) || selected_anchor)
292
-
293
- ##
294
- # @param text [String, #to_s]
295
- # @return [Integer]
296
- def word_count(text)
297
- (@word_counts ||= {})[text] ||= begin
298
- count = 0
299
- text.to_s.scan(/\p{Alnum}+/) { count += 1 }
300
- count
301
- end
302
- end
303
-
304
- def container_tokens(container)
305
- (@container_tokens ||= {}.compare_by_identity)[container] ||= "#{container['class']} #{container['id']}"
306
- end
307
-
308
- def content_tokens?(tokens)
309
- tokens.match?(CONTENT_REGEXP)
310
- end
311
-
312
- def junk_tokens?(tokens)
313
- tokens.match?(JUNK_REGEXP)
314
- end
315
-
316
168
  def stable_rank(entries)
317
169
  entries.sort_by { |entry| [-entry.final_score, entry.position] }
318
170
  end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module Html2rss
6
+ class AutoSource
7
+ module Scraper
8
+ ##
9
+ # Scrapes articles from XML sitemaps explicitly linked in HTML or discovered via standard sitemap paths.
10
+ class Sitemap
11
+ include Enumerable
12
+
13
+ # Selector for sitemap link tags in HTML head
14
+ SITEMAP_LINK_SELECTOR = 'link[rel="sitemap"][href]'
15
+
16
+ # Maximum number of sub-sitemaps to fetch when fanning out through a sitemapindex.
17
+ MAX_SUB_SITEMAPS = 3
18
+
19
+ # @return [Symbol] scraper config key
20
+ def self.options_key = :sitemap
21
+
22
+ ##
23
+ # Returns the number of request slots needed for sitemap discovery and fan-out.
24
+ #
25
+ # @param _opts [Hash] unused options
26
+ # @return [Integer] number of follow-up requests needed
27
+ def self.request_slots(_opts = {})
28
+ 1 + MAX_SUB_SITEMAPS
29
+ end
30
+
31
+ ##
32
+ # @param parsed_body [Nokogiri::HTML::Document, nil] parsed document
33
+ # @return [Boolean] whether the page links to a sitemap or is a sitemap
34
+ def self.articles?(parsed_body)
35
+ return false unless parsed_body
36
+
37
+ !parsed_body.at_css(SITEMAP_LINK_SELECTOR).nil? ||
38
+ !parsed_body.at_xpath('//*[local-name()="urlset" or local-name()="sitemapindex"]').nil?
39
+ end
40
+
41
+ ##
42
+ # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
43
+ # @param url [String, Html2rss::Url] canonical page URL
44
+ # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
45
+ # @option _opts [Float] :min_priority minimum priority score (default: 0.3)
46
+ # @option _opts [Integer] :max_age_days maximum entry age in days (default: 30)
47
+ def initialize(parsed_body, url:, request_session: nil, **opts)
48
+ @parsed_body = parsed_body
49
+ @url = Html2rss::Url.from_absolute(url)
50
+ @request_session = request_session
51
+ @min_priority = opts.fetch(:min_priority, Discovery::Sitemap::DEFAULT_MIN_PRIORITY)
52
+ @max_age_days = opts.fetch(:max_age_days, Discovery::Sitemap::DEFAULT_MAX_AGE_DAYS)
53
+ end
54
+
55
+ ##
56
+ # Yields article hashes from the sitemap.
57
+ #
58
+ # @yieldparam article [Hash{Symbol => Object}] normalized article hash
59
+ # @return [Enumerator, void] enumerator when no block is given
60
+ def each
61
+ return enum_for(:each) unless block_given?
62
+
63
+ entries = fetch_and_parse_entries
64
+ return if entries.empty?
65
+
66
+ entries.each do |entry|
67
+ article_hash = article_from_entry(entry)
68
+ yield article_hash if article_hash
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :parsed_body, :url, :request_session, :min_priority, :max_age_days
75
+
76
+ def fetch_and_parse_entries
77
+ entries = parse_direct_sitemap
78
+ return entries unless entries.empty?
79
+
80
+ xml_body = fetch_xml(target_sitemap_url)
81
+ return [] unless xml_body
82
+
83
+ parse_sitemap_xml(xml_body)
84
+ rescue Html2rss::RequestService::RequestBudgetExceeded
85
+ Log.warn("#{self.class}: sitemap fetch stopped — request budget exhausted")
86
+ []
87
+ end
88
+
89
+ def parse_sitemap_xml(xml_body)
90
+ result = Discovery::Sitemap.call(xml_body, min_priority:, max_age_days:)
91
+ return fetch_sub_sitemaps(result.sub_sitemap_urls) if result.sub_sitemap_urls.any?
92
+
93
+ result.entries
94
+ end
95
+
96
+ def parse_direct_sitemap
97
+ return [] unless parsed_body.at_xpath('//*[local-name()="urlset" or local-name()="sitemapindex"]')
98
+
99
+ Discovery::Sitemap.call(parsed_body.to_s, min_priority:, max_age_days:).entries
100
+ end
101
+
102
+ def fetch_sub_sitemaps(sub_urls)
103
+ entries = []
104
+ sub_urls.first(MAX_SUB_SITEMAPS).each do |sub_url|
105
+ sub_entries = fetch_sub_sitemap_entries(sub_url)
106
+ break unless sub_entries
107
+
108
+ entries.concat(sub_entries)
109
+ end
110
+ entries
111
+ end
112
+
113
+ def fetch_sub_sitemap_entries(sub_url)
114
+ xml = fetch_xml(sub_url)
115
+ return [] unless xml
116
+
117
+ Discovery::Sitemap.call(xml, min_priority:, max_age_days:).entries
118
+ rescue Html2rss::RequestService::RequestBudgetExceeded
119
+ Log.warn("#{self.class}: sitemap fan-out stopped — request budget exhausted")
120
+ nil
121
+ end
122
+
123
+ def fetch_xml(target_url)
124
+ return unless request_session && target_url
125
+
126
+ response = request_session.follow_up(url: target_url, relation: :auto_source, origin_url: url)
127
+ response&.body
128
+ rescue Html2rss::RequestService::RequestBudgetExceeded
129
+ raise
130
+ rescue Html2rss::Error => error
131
+ Log.warn("#{self.class}: failed to fetch sitemap (#{error.class}: #{error.message})")
132
+ nil
133
+ end
134
+
135
+ def target_sitemap_url
136
+ link_tag = parsed_body.at_css(SITEMAP_LINK_SELECTOR)
137
+ href = link_tag ? link_tag['href'] : '/sitemap.xml'
138
+ Html2rss::Url.from_relative(href, url)
139
+ end
140
+
141
+ def article_from_entry(entry)
142
+ return unless entry.url
143
+
144
+ {
145
+ url: entry.url,
146
+ title: entry.title,
147
+ published_at: entry.published_at
148
+ }.compact
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Html2rss
6
+ class AutoSource
7
+ module Scraper
8
+ class WordpressApi
9
+ class PageScope
10
+ ##
11
+ # Maps scoped archive path segments to WordPress REST +after+/+before+ bounds.
12
+ #
13
+ # Owns date-path shape detection and calendar range math only. Body-class
14
+ # taxonomy/author signals and pagination stay on {Resolver}.
15
+ class DateArchiveRange
16
+ ##
17
+ # @param segments [Array<String>] path segments with pagination already stripped
18
+ # @return [Hash{String => String}, nil] +after+/+before+ ISO8601 bounds, or +nil+
19
+ def self.from_segments(segments)
20
+ components = components_from(segments)
21
+ return unless components
22
+
23
+ start_date = Date.new(*components.fetch(:start_date_parts))
24
+ {
25
+ 'after' => iso8601_start(start_date),
26
+ 'before' => iso8601_start(next_archive_boundary(start_date, components.fetch(:precision)))
27
+ }
28
+ rescue Date::Error
29
+ nil
30
+ end
31
+
32
+ ##
33
+ # @param segments [Array<String>] path segments with pagination already stripped
34
+ # @return [Boolean] whether segments match a year[/month[/day]] archive shape
35
+ def self.path?(segments)
36
+ !date_segments_from(segments).nil?
37
+ end
38
+
39
+ class << self
40
+ private
41
+
42
+ def components_from(segments)
43
+ date_segments = date_segments_from(segments)
44
+ return unless date_segments
45
+
46
+ year = date_segments.fetch(0).to_i
47
+ month = parse_archive_segment(date_segments[1], 1, 12)
48
+ day = parse_archive_segment(date_segments[2], 1, 31)
49
+
50
+ {
51
+ start_date_parts: [year, month || 1, day || 1],
52
+ precision: archive_precision(month:, day:)
53
+ }
54
+ end
55
+
56
+ def date_segments_from(segments)
57
+ year_index = segments.find_index { _1.match?(/\A\d{4}\z/) }
58
+ return unless year_index
59
+
60
+ date_segments = segments.drop(year_index)
61
+ return unless date_segments.length.between?(1, 3)
62
+ return unless archive_segment_shape?(date_segments)
63
+
64
+ date_segments
65
+ end
66
+
67
+ def archive_segment_shape?(segments)
68
+ month = segments[1]
69
+ day = segments[2]
70
+ return false if day && month.nil?
71
+ return false unless month.nil? || month.match?(/\A\d+\z/)
72
+ return false unless day.nil? || day.match?(/\A\d+\z/)
73
+
74
+ true
75
+ end
76
+
77
+ def archive_precision(month:, day:)
78
+ return :day if day
79
+ return :month if month
80
+
81
+ :year
82
+ end
83
+
84
+ def next_archive_boundary(start_date, precision)
85
+ {
86
+ year: start_date.next_year,
87
+ month: start_date.next_month,
88
+ day: start_date.next_day
89
+ }.fetch(precision)
90
+ end
91
+
92
+ def iso8601_start(date)
93
+ date.strftime('%Y-%m-%dT00:00:00Z')
94
+ end
95
+
96
+ def parse_archive_segment(value, minimum, maximum)
97
+ return nil unless value&.match?(/\A\d+\z/)
98
+
99
+ number = value.to_i
100
+ return nil if number < minimum || number > maximum
101
+
102
+ number
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -99,7 +99,7 @@ module Html2rss
99
99
  def date_scope
100
100
  return unless date_archive?
101
101
 
102
- range = date_archive_range
102
+ range = DateArchiveRange.from_segments(scoped_path_segments)
103
103
  return unknown_archive_scope unless range
104
104
 
105
105
  PageScope.new(query: range, fetchable: true, reason: :archive)
@@ -195,45 +195,7 @@ module Html2rss
195
195
  end
196
196
 
197
197
  def date_archive_path?
198
- !date_archive_segments.nil?
199
- end
200
-
201
- def date_archive_range
202
- components = date_archive_components
203
- return unless components
204
-
205
- start_date = Date.new(*components.fetch(:start_date_parts))
206
- {
207
- 'after' => iso8601_start(start_date),
208
- 'before' => iso8601_start(next_archive_boundary(start_date, components.fetch(:precision)))
209
- }
210
- rescue Date::Error
211
- nil
212
- end
213
-
214
- def date_archive_components
215
- segments = date_archive_segments
216
- return unless segments
217
-
218
- year = segments.fetch(0).to_i
219
- month = parse_archive_segment(segments[1], 1, 12)
220
- day = parse_archive_segment(segments[2], 1, 31)
221
-
222
- {
223
- start_date_parts: [year, month || 1, day || 1],
224
- precision: archive_precision(month:, day:)
225
- }
226
- end
227
-
228
- def date_archive_segments
229
- year_index = scoped_path_segments.find_index { _1.match?(/\A\d{4}\z/) }
230
- return unless year_index
231
-
232
- segments = scoped_path_segments.drop(year_index)
233
- return unless segments.length.between?(1, 3)
234
- return unless archive_segment_shape?(segments)
235
-
236
- segments
198
+ DateArchiveRange.path?(scoped_path_segments)
237
199
  end
238
200
 
239
201
  def paginated_path?
@@ -253,48 +215,10 @@ module Html2rss
253
215
  parse_positive_integer(path_segments[-1])
254
216
  end
255
217
 
256
- def archive_segment_shape?(segments)
257
- month = segments[1]
258
- day = segments[2]
259
- return false if day && month.nil?
260
- return false unless month.nil? || month.match?(/\A\d+\z/)
261
- return false unless day.nil? || day.match?(/\A\d+\z/)
262
-
263
- true
264
- end
265
-
266
218
  def same_origin_url?(left, right)
267
219
  [left.scheme, left.host, left.port] == [right.scheme, right.host, right.port]
268
220
  end
269
221
 
270
- def archive_precision(month:, day:)
271
- return :day if day
272
- return :month if month
273
-
274
- :year
275
- end
276
-
277
- def next_archive_boundary(start_date, precision)
278
- {
279
- year: start_date.next_year,
280
- month: start_date.next_month,
281
- day: start_date.next_day
282
- }.fetch(precision)
283
- end
284
-
285
- def iso8601_start(date)
286
- date.strftime('%Y-%m-%dT00:00:00Z')
287
- end
288
-
289
- def parse_archive_segment(value, minimum, maximum)
290
- return nil unless value&.match?(/\A\d+\z/)
291
-
292
- number = value.to_i
293
- return nil if number < minimum || number > maximum
294
-
295
- number
296
- end
297
-
298
222
  def parse_positive_integer(value)
299
223
  return nil unless value.to_s.match?(/\A\d+\z/)
300
224
 
@@ -24,6 +24,15 @@ module Html2rss
24
24
  # @return [Symbol] scraper config key
25
25
  def self.options_key = :wordpress_api
26
26
 
27
+ ##
28
+ # Returns the number of request slots needed for WordPress API posts fetch.
29
+ #
30
+ # @param _opts [Hash] unused options
31
+ # @return [Integer] number of follow-up requests needed
32
+ def self.request_slots(_opts = {})
33
+ 1
34
+ end
35
+
27
36
  ##
28
37
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
29
38
  # @return [Boolean] whether the page advertises a WordPress REST API endpoint