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
|
@@ -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
|
|
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
|
|
65
|
-
def initialize(parsed_body, url:, extractor:
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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 || @
|
|
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
|
-
|
|
108
|
+
# Cheap path-only reject before title/DOM hard-junk observations.
|
|
109
|
+
next if destination_facts&.high_confidence_junk_path
|
|
121
110
|
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
130
|
-
junk_score:
|
|
131
|
-
final_score:
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|