html2rss 0.27.2 → 0.29.1
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 +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +32 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +268 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -40
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +48 -24
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Entry URL tournament for weak homepage/hub extracts.
|
|
6
|
+
#
|
|
7
|
+
# {include:file:lib/html2rss/feed_resolution/README.md}
|
|
8
|
+
module FeedResolution
|
|
9
|
+
# Default entry-resolution options merged into AutoSource::DEFAULT_CONFIG.
|
|
10
|
+
DEFAULT_CONFIG = {
|
|
11
|
+
enabled: true,
|
|
12
|
+
max_probes: 5
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Tournament outcome for one entry-resolution attempt.
|
|
17
|
+
Result = Data.define(:entry_url, :scrape_url, :applied, :reason, :probe_count, :winner_score)
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @param auto_source [Hash, nil]
|
|
21
|
+
# @return [Integer] entry-resolution budget slots (probes + retry GET when enabled)
|
|
22
|
+
def self.request_slots_for(auto_source)
|
|
23
|
+
return 0 unless auto_source
|
|
24
|
+
|
|
25
|
+
Options.from_auto_source(auto_source).request_slots
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @param entry_url [String, Html2rss::Url]
|
|
30
|
+
# @param response [Html2rss::RequestService::Response]
|
|
31
|
+
# @param session [Html2rss::RequestSession]
|
|
32
|
+
# @param config [Html2rss::Config]
|
|
33
|
+
# @param articles [Array<Html2rss::Article>]
|
|
34
|
+
# @param surface_category [Symbol, nil]
|
|
35
|
+
# @return [Result]
|
|
36
|
+
# rubocop:disable-next Metrics/ParameterLists -- tournament kwargs stay co-located
|
|
37
|
+
def self.call(entry_url:, response:, session:, config:, articles:, surface_category: nil)
|
|
38
|
+
Runner.new(
|
|
39
|
+
entry_url:, response:, session:, config:, articles:, surface_category:
|
|
40
|
+
).call
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Outcome of {FeedResolution.try_apply!} for the auto-fallback chain.
|
|
45
|
+
ApplyOutcome = Data.define(:scrape_target, :status)
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# Runs the tournament and, when a winner applies, re-fetches and re-extracts articles.
|
|
49
|
+
#
|
|
50
|
+
# @param pipeline [Html2rss::FeedPipeline]
|
|
51
|
+
# @param config [Html2rss::Config]
|
|
52
|
+
# @param response [Html2rss::RequestService::Response]
|
|
53
|
+
# @param session [Html2rss::RequestSession]
|
|
54
|
+
# @param strategy [Symbol]
|
|
55
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
|
|
56
|
+
# @param articles [Array]
|
|
57
|
+
# @param scrape_target [Html2rss::ScrapeTarget]
|
|
58
|
+
# @param state [Html2rss::FeedPipeline::AutoFallback::AttemptState]
|
|
59
|
+
# @param budget [Html2rss::RequestService::Budget]
|
|
60
|
+
# @return [ApplyOutcome, nil] `:succeeded` sticky scrape target when retry extract yielded items;
|
|
61
|
+
# `nil` when no winner or retry was empty (entry scrape target kept; Diag.applied may still be true)
|
|
62
|
+
# rubocop:disable-next Metrics/ParameterLists -- orchestration kwargs stay co-located
|
|
63
|
+
def self.try_apply!(pipeline:, config:, response:, session:, strategy:, resources:, articles:,
|
|
64
|
+
scrape_target:, state:, budget:)
|
|
65
|
+
Orchestrator.new(
|
|
66
|
+
pipeline:, config:, response:, session:, strategy:, resources:, articles:,
|
|
67
|
+
scrape_target:, state:, budget:
|
|
68
|
+
).call
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Tournament + optional retry orchestration for {FeedPipeline::AutoFallback}.
|
|
72
|
+
class Orchestrator
|
|
73
|
+
# Minimum remaining request budget required before probing candidates.
|
|
74
|
+
MIN_BUDGET_REMAINING = 3
|
|
75
|
+
|
|
76
|
+
# Error classes that abort resolution instead of falling through (mirrors AutoFallback).
|
|
77
|
+
NON_FALLBACK_ERRORS = FeedPipeline::AutoFallback::NON_FALLBACK_ERRORS
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# @param pipeline [Html2rss::FeedPipeline]
|
|
81
|
+
# @param config [Html2rss::Config]
|
|
82
|
+
# @param response [Html2rss::RequestService::Response]
|
|
83
|
+
# @param session [Html2rss::RequestSession]
|
|
84
|
+
# @param strategy [Symbol]
|
|
85
|
+
# @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
|
|
86
|
+
# @param articles [Array]
|
|
87
|
+
# @param scrape_target [Html2rss::ScrapeTarget]
|
|
88
|
+
# @param state [Html2rss::FeedPipeline::AutoFallback::AttemptState]
|
|
89
|
+
# @param budget [Html2rss::RequestService::Budget]
|
|
90
|
+
# rubocop:disable-next Metrics/ParameterLists -- orchestration context stays co-located
|
|
91
|
+
def initialize(pipeline:, config:, response:, session:, strategy:, resources:, articles:,
|
|
92
|
+
scrape_target:, state:, budget:)
|
|
93
|
+
@pipeline = pipeline
|
|
94
|
+
@config = config
|
|
95
|
+
@response = response
|
|
96
|
+
@session = session
|
|
97
|
+
@strategy = strategy
|
|
98
|
+
@resources = resources
|
|
99
|
+
@articles = articles
|
|
100
|
+
@scrape_target = scrape_target
|
|
101
|
+
@state = state
|
|
102
|
+
@budget = budget
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
##
|
|
106
|
+
# @return [ApplyOutcome, nil]
|
|
107
|
+
# rubocop:disable-next Metrics/MethodLength -- eligibility + tournament + retry path
|
|
108
|
+
def call
|
|
109
|
+
return unless eligible?
|
|
110
|
+
|
|
111
|
+
state.mark_resolution_tried!
|
|
112
|
+
resolution = run_tournament
|
|
113
|
+
state.remember_entry_resolution(resolution)
|
|
114
|
+
return unless resolution.applied
|
|
115
|
+
|
|
116
|
+
effective = scrape_target.with_effective(resolution.scrape_url)
|
|
117
|
+
return ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(
|
|
118
|
+
resolution, effective:
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
rescue RequestService::RequestBudgetExceeded
|
|
123
|
+
Log.warn('FeedResolution: entry resolution skipped (request budget exhausted)')
|
|
124
|
+
nil
|
|
125
|
+
rescue *NON_FALLBACK_ERRORS
|
|
126
|
+
raise
|
|
127
|
+
rescue StandardError => error
|
|
128
|
+
Log.warn("FeedResolution: entry resolution retry failed (#{error.class})")
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
attr_reader :pipeline, :config, :response, :session, :strategy, :resources, :articles,
|
|
135
|
+
:scrape_target, :state, :budget, :assessment
|
|
136
|
+
|
|
137
|
+
def eligible?
|
|
138
|
+
return false if state.resolution_tried?
|
|
139
|
+
return false if response.feed_response?
|
|
140
|
+
return false if budget.remaining_requests < MIN_BUDGET_REMAINING
|
|
141
|
+
|
|
142
|
+
@assessment = PageRecon.assess(response:, url: config.url)
|
|
143
|
+
Policy.resolve?(
|
|
144
|
+
config:, articles:, surface_category: assessment.surface_category
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def run_tournament
|
|
149
|
+
FeedResolution.call(
|
|
150
|
+
entry_url: config.url,
|
|
151
|
+
response:,
|
|
152
|
+
session:,
|
|
153
|
+
config:,
|
|
154
|
+
articles:,
|
|
155
|
+
surface_category: assessment.surface_category
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def retry_with(_resolution, effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
|
|
160
|
+
retry_session = pipeline.request_session_for(
|
|
161
|
+
config, strategy:, resources:, scrape_url: effective.effective_url
|
|
162
|
+
)
|
|
163
|
+
retry_response = retry_session.fetch_initial_response
|
|
164
|
+
retry_articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
|
|
165
|
+
config:, response: retry_response, request_session: retry_session
|
|
166
|
+
)
|
|
167
|
+
state.remember_response(retry_response)
|
|
168
|
+
state.record_items(
|
|
169
|
+
strategy:, items_count: retry_articles.size, transport_meta: retry_response.transport_meta
|
|
170
|
+
)
|
|
171
|
+
return unless retry_articles.size.positive?
|
|
172
|
+
|
|
173
|
+
state.succeed!(
|
|
174
|
+
response: retry_response, articles: retry_articles, dedup_dropped:,
|
|
175
|
+
selected_strategy: strategy, attempt_count: state.attempts.size, admission_drops:,
|
|
176
|
+
scrape_target: effective
|
|
177
|
+
)
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Internal tournament runner (keeps {FeedResolution} a Zeitwerk namespace module).
|
|
183
|
+
class Runner
|
|
184
|
+
##
|
|
185
|
+
# @param entry_url [String, Html2rss::Url]
|
|
186
|
+
# @param response [Html2rss::RequestService::Response]
|
|
187
|
+
# @param session [Html2rss::RequestSession]
|
|
188
|
+
# @param config [Html2rss::Config]
|
|
189
|
+
# @param articles [Array<Html2rss::Article>]
|
|
190
|
+
# @param surface_category [Symbol, nil]
|
|
191
|
+
# rubocop:disable-next Metrics/ParameterLists -- tournament context stays co-located
|
|
192
|
+
def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
|
|
193
|
+
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
194
|
+
|
|
195
|
+
@entry_url = Html2rss::Url.from_absolute(entry_url)
|
|
196
|
+
@response = response
|
|
197
|
+
@session = session
|
|
198
|
+
@config = config
|
|
199
|
+
@articles = articles
|
|
200
|
+
@surface_category = surface_category
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
##
|
|
204
|
+
# @return [Result]
|
|
205
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- policy → candidates → probe → select
|
|
206
|
+
def call
|
|
207
|
+
return skip(:policy_skip) unless Policy.resolve?(
|
|
208
|
+
config:, articles:, surface_category:
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
candidates = CandidateGenerator.call(
|
|
212
|
+
entry_url:,
|
|
213
|
+
response:,
|
|
214
|
+
max: max_probes
|
|
215
|
+
)
|
|
216
|
+
return skip(:no_candidates) if candidates.empty?
|
|
217
|
+
|
|
218
|
+
scored = probe_candidates(candidates)
|
|
219
|
+
winner = Scorer.pick_winner(scored:, entry_articles_count: articles.size)
|
|
220
|
+
return skip(:no_winner, probe_count: scored.size) unless winner
|
|
221
|
+
|
|
222
|
+
apply(winner, probe_count: scored.size)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
attr_reader :entry_url, :response, :session, :config, :articles, :surface_category
|
|
228
|
+
|
|
229
|
+
def max_probes
|
|
230
|
+
Options.from_auto_source(config.auto_source).max_probes
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def probe_candidates(candidates)
|
|
234
|
+
probe = Probe.new(request_session: session, origin_url: entry_url)
|
|
235
|
+
candidates.filter_map { |url| probe.call(url) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def apply(winner, probe_count:)
|
|
239
|
+
result(
|
|
240
|
+
applied: true,
|
|
241
|
+
reason: :winner,
|
|
242
|
+
scrape_url: winner.url.to_s,
|
|
243
|
+
probe_count:,
|
|
244
|
+
winner_score: winner.score
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def skip(reason, probe_count: 0)
|
|
249
|
+
result(
|
|
250
|
+
applied: false,
|
|
251
|
+
reason:,
|
|
252
|
+
scrape_url: entry_url.to_s,
|
|
253
|
+
probe_count:,
|
|
254
|
+
winner_score: nil
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def result(applied:, reason:, scrape_url:, probe_count:, winner_score:)
|
|
259
|
+
scrape_host = Html2rss::Url.from_absolute(scrape_url).host
|
|
260
|
+
Log.info(
|
|
261
|
+
"FeedResolution: entry_host=#{entry_url.host} scrape_host=#{scrape_host} " \
|
|
262
|
+
"applied=#{applied} probe_count=#{probe_count} winner_score=#{winner_score.inspect} reason=#{reason}"
|
|
263
|
+
)
|
|
264
|
+
Result.new(entry_url: entry_url.to_s, scrape_url:, applied:, reason:, probe_count:, winner_score:)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -19,26 +19,22 @@ module Html2rss
|
|
|
19
19
|
tags = article_tag.css(HEADING_TAGS.join(','))
|
|
20
20
|
if tags.any?
|
|
21
21
|
select_best_heading(tags)
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
else
|
|
23
|
+
labeled = heading_from_aria_or_title(article_tag)
|
|
24
|
+
return labeled if labeled
|
|
25
|
+
|
|
26
|
+
fallback_heading(article_tag) if fallback_anchorless && selected_anchor.nil?
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
private
|
|
28
31
|
|
|
29
32
|
def select_best_heading(tags)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
next if tag.name != min_tag_name
|
|
36
|
-
|
|
37
|
-
size = Navigator::TextExtractor.call(tag)&.size.to_i
|
|
38
|
-
(best_tag = tag) && (max_size = size) if size > max_size
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
best_tag
|
|
33
|
+
tagged = tags.map { |tag| [Probe.tag(tag), tag] }
|
|
34
|
+
min_tag_name = tagged.map(&:first).min
|
|
35
|
+
tagged.select { |name, _tag| name == min_tag_name }
|
|
36
|
+
.max_by { |_name, tag| Navigator::TextExtractor.call(tag)&.size.to_i }
|
|
37
|
+
&.last
|
|
42
38
|
end
|
|
43
39
|
|
|
44
40
|
def fallback_heading(article_tag)
|
|
@@ -47,6 +43,20 @@ module Html2rss
|
|
|
47
43
|
)
|
|
48
44
|
fallback_tags.find { |t| !Navigator::TextExtractor.call(t).to_s.strip.empty? }
|
|
49
45
|
end
|
|
46
|
+
|
|
47
|
+
def heading_from_aria_or_title(article_tag)
|
|
48
|
+
article_tag.css('[aria-label]').each do |node|
|
|
49
|
+
next if node['aria-label'].to_s.strip.empty?
|
|
50
|
+
|
|
51
|
+
return node
|
|
52
|
+
end
|
|
53
|
+
article_tag.css('[title]').each do |node|
|
|
54
|
+
next if node['title'].to_s.strip.empty?
|
|
55
|
+
|
|
56
|
+
return node
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
50
60
|
end
|
|
51
61
|
end
|
|
52
62
|
end
|
|
@@ -5,7 +5,7 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# ArticleExtractor is responsible for extracting details (headline, url, images, etc.)
|
|
7
7
|
# from an article_tag DOM node. DOM chrome helpers live on {Navigator}.
|
|
8
|
-
# rubocop:disable Metrics/ClassLength -- leftover re-extract stays with field extractors
|
|
8
|
+
# rubocop:disable-next Metrics/ClassLength -- leftover re-extract stays with field extractors
|
|
9
9
|
class ArticleExtractor
|
|
10
10
|
class << self
|
|
11
11
|
##
|
|
@@ -79,7 +79,7 @@ module Html2rss
|
|
|
79
79
|
Url.from_relative("##{id}", base_url) if id
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
|
82
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
83
83
|
def extract_title
|
|
84
84
|
source = heading || selected_anchor
|
|
85
85
|
title_text = source ? Navigator.extract_visible_text(source) : fallback_anchorless_title
|
|
@@ -88,7 +88,6 @@ module Html2rss
|
|
|
88
88
|
kicker = kicker_node ? Navigator.extract_visible_text(kicker_node).to_s.strip : nil
|
|
89
89
|
kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
|
|
90
90
|
end
|
|
91
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
|
92
91
|
|
|
93
92
|
def fallback_anchorless_title
|
|
94
93
|
return unless @fallback_anchorless && selected_anchor.nil?
|
|
@@ -130,11 +129,8 @@ module Html2rss
|
|
|
130
129
|
end
|
|
131
130
|
|
|
132
131
|
def heading_or_anchor_item?
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def heading_item?
|
|
137
|
-
Navigator::HEADING_TAGS.include?(article_tag.name.to_s)
|
|
132
|
+
tag = Probe.tag(article_tag)
|
|
133
|
+
Navigator::HEADING_TAGS.include?(tag) || tag == 'a'
|
|
138
134
|
end
|
|
139
135
|
|
|
140
136
|
def heading_or_anchor_miss?(title, lines, published_at)
|
|
@@ -207,6 +203,5 @@ module Html2rss
|
|
|
207
203
|
|
|
208
204
|
def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
|
|
209
205
|
end
|
|
210
|
-
# rubocop:enable Metrics/ClassLength
|
|
211
206
|
end
|
|
212
207
|
end
|
|
@@ -25,8 +25,10 @@ module Html2rss
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def feed_mime_type(type)
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
return Probe::APPLICATION_RSS_XML if Probe.mime_match?(type, Probe::APPLICATION_RSS_XML)
|
|
29
|
+
return Probe::APPLICATION_ATOM_XML if Probe.mime_match?(type, Probe::APPLICATION_ATOM_XML)
|
|
30
|
+
|
|
31
|
+
nil
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
34
|
end
|
|
@@ -11,9 +11,6 @@ module Html2rss
|
|
|
11
11
|
# Element tags that indicate ignored DOM chrome when found in a container path.
|
|
12
12
|
IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
|
|
13
13
|
|
|
14
|
-
# Layout roots and chrome tags excluded from class-clustering candidate nodes.
|
|
15
|
-
CLUSTER_EXCLUDED_TAGS = Set['html', 'body', 'nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
|
|
16
|
-
|
|
17
14
|
# Ancestor tags that usually indicate navigation/utility regions inside a content container.
|
|
18
15
|
UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].to_set.freeze
|
|
19
16
|
|
|
@@ -48,43 +45,11 @@ module Html2rss
|
|
|
48
45
|
# @param article_tag [Nokogiri::XML::Node] article-like container to search within
|
|
49
46
|
# @return [Nokogiri::XML::Node, nil] first eligible descendant anchor
|
|
50
47
|
def main_anchor_for(article_tag)
|
|
51
|
-
return article_tag if article_tag
|
|
48
|
+
return article_tag if Probe.tag(article_tag) == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
|
|
52
49
|
|
|
53
50
|
article_tag.at_css(MAIN_ANCHOR_SELECTOR)
|
|
54
51
|
end
|
|
55
52
|
|
|
56
|
-
##
|
|
57
|
-
# @param node [Nokogiri::XML::Node]
|
|
58
|
-
# @param cache [Hash, nil] identity cache used to store results (must use compare_by_identity)
|
|
59
|
-
# @return [Boolean] true when the node belongs to ignored DOM chrome
|
|
60
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
61
|
-
def ignored_container_path?(node, cache = nil)
|
|
62
|
-
return cache[node] if cache&.key?(node)
|
|
63
|
-
|
|
64
|
-
curr = node
|
|
65
|
-
visited = []
|
|
66
|
-
is_ignored = false
|
|
67
|
-
|
|
68
|
-
while curr.respond_to?(:parent) && curr
|
|
69
|
-
if cache&.key?(curr)
|
|
70
|
-
is_ignored = cache[curr]
|
|
71
|
-
break
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
if IGNORED_CONTAINER_TAGS.include?(curr.name)
|
|
75
|
-
is_ignored = true
|
|
76
|
-
break
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
visited << curr
|
|
80
|
-
curr = curr.parent
|
|
81
|
-
end
|
|
82
|
-
visited.each { |n| cache[n] = is_ignored } if cache
|
|
83
|
-
|
|
84
|
-
is_ignored
|
|
85
|
-
end
|
|
86
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
87
|
-
|
|
88
53
|
##
|
|
89
54
|
# Returns the first parent that satisfies the condition.
|
|
90
55
|
# If the condition is met, it returns the node itself.
|
|
@@ -93,7 +58,7 @@ module Html2rss
|
|
|
93
58
|
# @param condition [Proc] The condition to be met.
|
|
94
59
|
# @return [Nokogiri::XML::Node, nil] The first parent that satisfies the condition.
|
|
95
60
|
def parent_until_condition(node, condition)
|
|
96
|
-
while node && !node.document? && node
|
|
61
|
+
while node && !node.document? && Probe.tag(node) != 'html'
|
|
97
62
|
return node if condition.call(node)
|
|
98
63
|
|
|
99
64
|
node = node.parent
|
|
@@ -109,7 +74,7 @@ module Html2rss
|
|
|
109
74
|
return false unless node
|
|
110
75
|
return false if node.respond_to?(:document?) && node.document?
|
|
111
76
|
|
|
112
|
-
!CARD_WALK_STOP_TAGS.include?(node
|
|
77
|
+
!CARD_WALK_STOP_TAGS.include?(Probe.tag(node))
|
|
113
78
|
end
|
|
114
79
|
|
|
115
80
|
##
|
|
@@ -137,7 +102,7 @@ module Html2rss
|
|
|
137
102
|
# @param tag_name [String] tag name to find in ancestors
|
|
138
103
|
# @return [Nokogiri::XML::Node, nil] matching ancestor node
|
|
139
104
|
def find_tag_in_ancestors(current_tag, tag_name)
|
|
140
|
-
return current_tag if current_tag
|
|
105
|
+
return current_tag if Probe.tag(current_tag) == Probe.fold(tag_name)
|
|
141
106
|
|
|
142
107
|
current_tag.ancestors(tag_name).first
|
|
143
108
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
##
|
|
6
|
+
# Case-insensitive DOM wire matching for MIME types, tag names, and link relations.
|
|
7
|
+
module Probe
|
|
8
|
+
# Canonical MIME type strings for script and alternate link matching.
|
|
9
|
+
APPLICATION_LD_JSON = 'application/ld+json'
|
|
10
|
+
# @see #mime_match?
|
|
11
|
+
APPLICATION_JSON = 'application/json'
|
|
12
|
+
# @see #mime_match?
|
|
13
|
+
APPLICATION_JSON_OEMBED = 'application/json+oembed'
|
|
14
|
+
# @see #mime_match?
|
|
15
|
+
APPLICATION_RSS_XML = 'application/rss+xml'
|
|
16
|
+
# @see #mime_match?
|
|
17
|
+
APPLICATION_ATOM_XML = 'application/atom+xml'
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @param string [String, Symbol, #to_s] wire value
|
|
23
|
+
# @return [String] folded comparison key
|
|
24
|
+
def fold(string)
|
|
25
|
+
string.to_s.downcase
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @param node [Nokogiri::XML::Node]
|
|
30
|
+
# @return [String] folded element name
|
|
31
|
+
def tag(node)
|
|
32
|
+
name = node.name
|
|
33
|
+
return fold(name) unless name.is_a?(String)
|
|
34
|
+
return name if name.ascii_only? && !name.match?(/[A-Z]/)
|
|
35
|
+
|
|
36
|
+
fold(name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @param type [String, Symbol, #to_s, nil] MIME type attribute value
|
|
41
|
+
# @return [String] media type without parameters, folded
|
|
42
|
+
def mime_base(type)
|
|
43
|
+
raw = type.to_s
|
|
44
|
+
return fold(raw) unless raw.include?(';') || raw.match?(/\A\s|\s\z/)
|
|
45
|
+
|
|
46
|
+
fold(raw.split(';', 2).first.to_s.strip)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# @param actual [String, Symbol, #to_s, nil] observed MIME type
|
|
51
|
+
# @param expected [Array<String>] canonical MIME types to match
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def mime_match?(actual, *expected)
|
|
54
|
+
base = mime_base(actual)
|
|
55
|
+
expected.any? { |candidate| base == candidate || base == mime_base(candidate) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# @param doc [Nokogiri::XML::Node]
|
|
60
|
+
# @param mime_types [Array<String>] optional MIME filters (folded once per call)
|
|
61
|
+
# @return [Array<Nokogiri::XML::Element>]
|
|
62
|
+
def scripts(doc, *mime_types)
|
|
63
|
+
return doc.css('script') if mime_types.empty?
|
|
64
|
+
|
|
65
|
+
allowed = mime_types.to_set { |mime| mime_base(mime) }
|
|
66
|
+
doc.css('script').select { |script| allowed.include?(mime_base(script['type'])) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# @param doc [Nokogiri::XML::Node]
|
|
71
|
+
# @param rel [String] link rel token
|
|
72
|
+
# @param mime [String, nil] optional MIME filter
|
|
73
|
+
# @return [Array<Nokogiri::XML::Element>]
|
|
74
|
+
def alternate_links(doc, rel:, mime: nil)
|
|
75
|
+
nodes = doc.css(%(link[rel~="#{rel}"][href]))
|
|
76
|
+
return nodes if mime.nil?
|
|
77
|
+
|
|
78
|
+
nodes.select { |node| mime_match?(node['type'], mime) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -7,7 +7,7 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# Builds an {Html2rss::Article} from an SST segment / ranked segment.
|
|
9
9
|
# Port of Html::ArticleExtractor field logic onto SST::Node.
|
|
10
|
-
# rubocop:disable Metrics/ClassLength -- SST field extractors colocated for parity with ArticleExtractor
|
|
10
|
+
# rubocop:disable-next Metrics/ClassLength -- SST field extractors colocated for parity with ArticleExtractor
|
|
11
11
|
class SstArticleExtractor
|
|
12
12
|
# CSS class tokens that mark kicker / eyebrow text (excluded from titles).
|
|
13
13
|
KICKER_CLASS_PATTERN = /kicker|eyebrow|pre-title|pretitle|overline/i
|
|
@@ -390,6 +390,5 @@ module Html2rss
|
|
|
390
390
|
end
|
|
391
391
|
end
|
|
392
392
|
end
|
|
393
|
-
# rubocop:enable Metrics/ClassLength
|
|
394
393
|
end
|
|
395
394
|
end
|
|
@@ -21,7 +21,7 @@ module Html2rss
|
|
|
21
21
|
# @param heading_anchor [Boolean]
|
|
22
22
|
# @param utility_landmark_ancestor [Boolean] pre-computed by Segmenter tree walk
|
|
23
23
|
# @return [Boolean]
|
|
24
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
|
|
24
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
|
|
25
25
|
def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil,
|
|
26
26
|
heading_anchor: false, utility_landmark_ancestor: false)
|
|
27
27
|
return true unless destination_facts
|
|
@@ -35,7 +35,6 @@ module Html2rss
|
|
|
35
35
|
icon_only_anchor?(anchor, text) ||
|
|
36
36
|
utility_landmark_ancestor
|
|
37
37
|
end
|
|
38
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
|
|
39
38
|
|
|
40
39
|
private
|
|
41
40
|
|