html2rss 0.30.0 → 0.31.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 +2 -2
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +1 -1
- data/lib/html2rss/auto_source/cleanup.rb +15 -6
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -14
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +2 -2
- data/lib/html2rss/batch.rb +3 -1
- data/lib/html2rss/capture/README.md +6 -4
- data/lib/html2rss/capture.rb +67 -35
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +1 -1
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +2 -3
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +35 -113
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +6 -4
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +2 -0
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +6 -20
- data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +111 -131
- data/lib/html2rss/mcp/server.rb +26 -18
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +19 -30
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/compressed_body.rb +4 -1
- data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/strategy.rb +3 -8
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -173
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +21 -13
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +15 -6
- data/lib/html2rss/selectors/item_scope.rb +0 -30
data/lib/html2rss/defaults.rb
CHANGED
|
@@ -4,13 +4,20 @@ module Html2rss
|
|
|
4
4
|
##
|
|
5
5
|
# Global defaults for the Html2rss gem.
|
|
6
6
|
class Defaults
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
# Named Logger severities accepted by {#log_level=}.
|
|
8
|
+
LOG_LEVELS = {
|
|
9
|
+
debug: Logger::DEBUG,
|
|
10
|
+
info: Logger::INFO,
|
|
11
|
+
warn: Logger::WARN,
|
|
12
|
+
error: Logger::ERROR,
|
|
13
|
+
fatal: Logger::FATAL,
|
|
14
|
+
unknown: Logger::UNKNOWN
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# @return [Logger] the logger
|
|
11
18
|
attr_reader :logger
|
|
12
19
|
|
|
13
|
-
# @return [Proc, nil] the logger formatter
|
|
20
|
+
# @return [Proc, Logger::Formatter, nil] the logger formatter
|
|
14
21
|
attr_reader :logger_formatter
|
|
15
22
|
|
|
16
23
|
# @return [Symbol, Integer] the current log level
|
|
@@ -46,12 +53,15 @@ module Html2rss
|
|
|
46
53
|
##
|
|
47
54
|
# Sets the logger.
|
|
48
55
|
#
|
|
49
|
-
# @param logger [
|
|
50
|
-
# @return [
|
|
56
|
+
# @param logger [Logger]
|
|
57
|
+
# @return [Logger] the logger
|
|
58
|
+
# @raise [ArgumentError] if logger is not a Logger
|
|
51
59
|
def logger=(logger)
|
|
60
|
+
raise ArgumentError, 'logger must be a Logger' unless logger.is_a?(::Logger)
|
|
61
|
+
|
|
52
62
|
@logger = logger
|
|
53
|
-
@logger.level = @log_level
|
|
54
|
-
@logger.formatter = @logger_formatter if @logger_formatter
|
|
63
|
+
@logger.level = @log_level
|
|
64
|
+
@logger.formatter = @logger_formatter if @logger_formatter
|
|
55
65
|
end
|
|
56
66
|
|
|
57
67
|
##
|
|
@@ -62,34 +72,39 @@ module Html2rss
|
|
|
62
72
|
# @raise [ArgumentError] if the log level is invalid
|
|
63
73
|
def log_level=(level)
|
|
64
74
|
@log_level = normalize_log_level(level)
|
|
65
|
-
@logger.level = @log_level
|
|
75
|
+
@logger.level = @log_level
|
|
66
76
|
end
|
|
67
77
|
|
|
68
78
|
##
|
|
69
79
|
# Sets the logger formatter.
|
|
70
80
|
#
|
|
71
|
-
# @param formatter [Proc,
|
|
72
|
-
# @return [Proc,
|
|
73
|
-
# @raise [ArgumentError] if formatter
|
|
81
|
+
# @param formatter [Proc, Logger::Formatter, nil] the new logger formatter
|
|
82
|
+
# @return [Proc, Logger::Formatter, nil] the new logger formatter
|
|
83
|
+
# @raise [ArgumentError] if formatter is not a Proc, Logger::Formatter, or nil
|
|
74
84
|
def logger_formatter=(formatter)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
@logger_formatter = case formatter
|
|
86
|
+
when nil, Proc, Logger::Formatter
|
|
87
|
+
formatter
|
|
88
|
+
else
|
|
89
|
+
raise ArgumentError, 'formatter must be a Proc, Logger::Formatter, or nil'
|
|
90
|
+
end
|
|
91
|
+
@logger.formatter = @logger_formatter
|
|
79
92
|
end
|
|
80
93
|
|
|
81
94
|
##
|
|
82
95
|
# Sets the global request headers.
|
|
83
96
|
#
|
|
84
|
-
# @param headers [Hash, Proc,
|
|
85
|
-
# @return [Hash, Proc,
|
|
86
|
-
# @raise [ArgumentError] if headers is not a Hash or
|
|
97
|
+
# @param headers [Hash, Proc, nil] the HTTP request headers to globally apply
|
|
98
|
+
# @return [Hash, Proc, nil] the assigned headers
|
|
99
|
+
# @raise [ArgumentError] if headers is not a Hash, Proc, or nil
|
|
87
100
|
def headers=(headers)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
@headers = case headers
|
|
102
|
+
when nil then nil
|
|
103
|
+
when Hash then headers.dup.freeze
|
|
104
|
+
when Proc then headers
|
|
105
|
+
else
|
|
106
|
+
raise ArgumentError, 'headers must be a Hash or Proc'
|
|
107
|
+
end
|
|
93
108
|
end
|
|
94
109
|
|
|
95
110
|
##
|
|
@@ -164,14 +179,11 @@ module Html2rss
|
|
|
164
179
|
|
|
165
180
|
def normalize_log_level(level)
|
|
166
181
|
if level.is_a?(Integer)
|
|
167
|
-
raise ArgumentError, "invalid log level: #{level}" unless
|
|
182
|
+
raise ArgumentError, "invalid log level: #{level}" unless LOG_LEVELS.value?(level)
|
|
168
183
|
|
|
169
184
|
level
|
|
170
185
|
else
|
|
171
|
-
|
|
172
|
-
raise ArgumentError, "invalid log level: #{level}" unless VALID_LOG_LEVELS.include?(sym)
|
|
173
|
-
|
|
174
|
-
Logger.const_get(sym.upcase)
|
|
186
|
+
LOG_LEVELS.fetch(level.to_s.downcase.to_sym) { raise ArgumentError, "invalid log level: #{level}" }
|
|
175
187
|
end
|
|
176
188
|
end
|
|
177
189
|
end
|
|
@@ -4,15 +4,15 @@ require 'httpx'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
|
|
6
6
|
module Html2rss
|
|
7
|
-
##
|
|
8
|
-
# CLI runtime health checks for optional scrape dependencies.
|
|
9
7
|
module Doctor
|
|
10
8
|
##
|
|
11
9
|
# Preflight checks for Botasaurus scrape-api connectivity.
|
|
12
10
|
module Botasaurus
|
|
11
|
+
Check = Data.define(:name, :ok, :detail)
|
|
12
|
+
|
|
13
13
|
##
|
|
14
14
|
# One named doctor check outcome.
|
|
15
|
-
Check
|
|
15
|
+
class Check
|
|
16
16
|
##
|
|
17
17
|
# @return [Hash{Symbol => Object}]
|
|
18
18
|
def to_h
|
|
@@ -20,9 +20,11 @@ module Html2rss
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
Result = Data.define(:ok, :checks, :message)
|
|
24
|
+
|
|
23
25
|
##
|
|
24
26
|
# Aggregate doctor command outcome.
|
|
25
|
-
Result
|
|
27
|
+
class Result
|
|
26
28
|
##
|
|
27
29
|
# @return [Hash{Symbol => Object}]
|
|
28
30
|
def to_h
|
data/lib/html2rss/error.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Html2rss
|
|
|
7
7
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
8
8
|
class NoFeedItemsExtracted < Error
|
|
9
9
|
# Categories that append shared surface guidance to the empty-feed message.
|
|
10
|
-
SURFACE_HINT_CATEGORIES =
|
|
10
|
+
SURFACE_HINT_CATEGORIES = Set[:app_shell, :blocked_surface, :high_entropy_surface].freeze
|
|
11
11
|
|
|
12
12
|
##
|
|
13
13
|
# @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
|
|
@@ -34,16 +34,10 @@ module Html2rss
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
36
|
def add_item_string_values(article, item_maker)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}.each do |attr, value|
|
|
42
|
-
next if value.nil? || value.empty?
|
|
43
|
-
|
|
44
|
-
item_maker.public_send(:"#{attr}=", value)
|
|
45
|
-
end
|
|
46
|
-
|
|
37
|
+
item_maker.title = article.title
|
|
38
|
+
item_maker.author = article.author
|
|
39
|
+
description = ItemPresentation.description_for(article)
|
|
40
|
+
item_maker.description = description if description && !description.empty?
|
|
47
41
|
item_maker.link = article.url.to_s if article.url
|
|
48
42
|
item_maker.pubDate = article.published_at&.rfc2822
|
|
49
43
|
end
|
|
@@ -96,12 +90,13 @@ module Html2rss
|
|
|
96
90
|
@stylesheets.map { |style| Stylesheet.new(**style) }
|
|
97
91
|
end
|
|
98
92
|
|
|
99
|
-
# rubocop:disable-next Metrics/AbcSize
|
|
93
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
|
100
94
|
def make_channel(maker)
|
|
101
95
|
channel_maker = maker.channel
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
96
|
+
channel_maker.language = channel.language
|
|
97
|
+
channel_maker.title = channel.title
|
|
98
|
+
channel_maker.description = channel.description
|
|
99
|
+
channel_maker.ttl = channel.ttl
|
|
105
100
|
|
|
106
101
|
channel_maker.managingEditor = channel.author if channel.author
|
|
107
102
|
channel_maker.author = channel.author if channel.author
|
|
@@ -45,6 +45,20 @@ module Html2rss
|
|
|
45
45
|
# @return [Html2rss::RequestService::Budget] shared budget for the feed build
|
|
46
46
|
def self.budget_for(config) = resources_for(config).budget
|
|
47
47
|
|
|
48
|
+
##
|
|
49
|
+
# Reserve enough HTTP request slots for the initial request plus predictable
|
|
50
|
+
# follow-ups.
|
|
51
|
+
#
|
|
52
|
+
# @param config [Html2rss::Config] validated feed config
|
|
53
|
+
# @return [Integer] number of reserved baseline request slots
|
|
54
|
+
def self.baseline_request_budget_for(config)
|
|
55
|
+
1 +
|
|
56
|
+
RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
|
|
57
|
+
AutoSource.request_slots_for(config.auto_source) +
|
|
58
|
+
FeedResolution.request_slots_for(config.auto_source) +
|
|
59
|
+
StrategyPlan.resolve(config.strategy).request_slots
|
|
60
|
+
end
|
|
61
|
+
|
|
48
62
|
class << self
|
|
49
63
|
private
|
|
50
64
|
|
|
@@ -53,16 +67,6 @@ module Html2rss
|
|
|
53
67
|
|
|
54
68
|
[baseline_request_budget_for(config), config.max_requests].max
|
|
55
69
|
end
|
|
56
|
-
|
|
57
|
-
# Reserve enough HTTP request slots for the initial request plus predictable
|
|
58
|
-
# follow-ups.
|
|
59
|
-
def baseline_request_budget_for(config)
|
|
60
|
-
1 +
|
|
61
|
-
RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
|
|
62
|
-
AutoSource.request_slots_for(config.auto_source) +
|
|
63
|
-
FeedResolution.request_slots_for(config.auto_source) +
|
|
64
|
-
StrategyPlan.resolve(config.strategy).request_slots
|
|
65
|
-
end
|
|
66
70
|
end
|
|
67
71
|
end
|
|
68
72
|
end
|
|
@@ -8,16 +8,20 @@ module Html2rss
|
|
|
8
8
|
# {RequestService} executes concrete adapters only. +:auto+ is resolved here before
|
|
9
9
|
# any session or adapter call — plan vs transport locality.
|
|
10
10
|
class StrategyPlan
|
|
11
|
+
Auto = Data.define
|
|
12
|
+
|
|
11
13
|
# Feed-level auto plan: try {AutoFallback::CHAIN} until items are extracted.
|
|
12
|
-
Auto
|
|
14
|
+
class Auto
|
|
13
15
|
# @return [Integer]
|
|
14
16
|
def request_slots
|
|
15
17
|
[AutoFallback::CHAIN.size - 1, 0].max
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
Concrete = Data.define(:strategy)
|
|
22
|
+
|
|
19
23
|
# Concrete transport strategy name for {RequestService}.
|
|
20
|
-
Concrete
|
|
24
|
+
class Concrete
|
|
21
25
|
# @return [Integer]
|
|
22
26
|
def request_slots = 0
|
|
23
27
|
end
|
|
@@ -99,11 +99,8 @@ module Html2rss
|
|
|
99
99
|
|
|
100
100
|
def segment_urls_for(sst, strategy, link_resolver)
|
|
101
101
|
segmenter = AutoSource::Segmenter.new(sst, base_url: entry_url, strategy:, link_resolver:)
|
|
102
|
-
primary = AutoSource::Segmenter::PrimaryLink.new(segmenter)
|
|
103
|
-
|
|
104
102
|
segmenter.call.filter_map do |segment|
|
|
105
|
-
|
|
106
|
-
absolute_same_origin(link&.attrs&.href)
|
|
103
|
+
absolute_same_origin(segment.primary_link&.attrs&.href)
|
|
107
104
|
end.uniq
|
|
108
105
|
end
|
|
109
106
|
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module FeedResolution
|
|
5
|
+
Diag = Data.define(:applied, :probe_count, :reason, :winner_score)
|
|
6
|
+
|
|
5
7
|
##
|
|
6
8
|
# Wire-safe diagnostics for one entry-resolution attempt (Status / Marshal edge).
|
|
7
|
-
Diag
|
|
9
|
+
class Diag
|
|
8
10
|
##
|
|
9
11
|
# @param result [Html2rss::FeedResolution::Result]
|
|
10
12
|
# @return [Diag]
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module FeedResolution
|
|
5
|
+
Options = Data.define(:enabled, :max_probes)
|
|
6
|
+
|
|
5
7
|
##
|
|
6
8
|
# Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
|
|
7
|
-
Options
|
|
9
|
+
class Options
|
|
8
10
|
##
|
|
9
11
|
# @param auto_source [Hash, nil]
|
|
10
12
|
# @return [Options]
|
|
@@ -13,13 +13,13 @@ module Html2rss
|
|
|
13
13
|
##
|
|
14
14
|
# @param config [Html2rss::Config]
|
|
15
15
|
# @param articles [Array<Html2rss::Article>] typed extract results (count derived)
|
|
16
|
-
# @param surface_category [Html2rss::SurfaceCategory, Symbol
|
|
16
|
+
# @param surface_category [Html2rss::SurfaceCategory, Symbol]
|
|
17
17
|
# @return [Boolean]
|
|
18
18
|
def resolve?(config:, articles:, surface_category:)
|
|
19
19
|
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
20
20
|
return false unless eligible_config?(config)
|
|
21
21
|
|
|
22
|
-
category = SurfaceCategory
|
|
22
|
+
category = SurfaceCategory[surface_category]
|
|
23
23
|
return false if category.blocked?
|
|
24
24
|
|
|
25
25
|
articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
|
|
@@ -10,7 +10,7 @@ module Html2rss
|
|
|
10
10
|
# @param time_zone [String] channel time zone for naive values
|
|
11
11
|
# @return [DateTime, nil]
|
|
12
12
|
def self.call(article_tag, leftover_lines: [], time_zone: 'UTC')
|
|
13
|
-
datetimes = article_tag.
|
|
13
|
+
datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
|
|
14
14
|
ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -124,8 +124,7 @@ module Html2rss
|
|
|
124
124
|
end
|
|
125
125
|
|
|
126
126
|
def leftover_exclude_nodes_for(node)
|
|
127
|
-
|
|
128
|
-
[heading, selected_anchor, kicker_node, *times].compact
|
|
127
|
+
[heading, selected_anchor, kicker_node, *node.css('time')].compact
|
|
129
128
|
end
|
|
130
129
|
|
|
131
130
|
def heading_or_anchor_item?
|
|
@@ -156,8 +155,7 @@ module Html2rss
|
|
|
156
155
|
end
|
|
157
156
|
|
|
158
157
|
def immediate_card_parent
|
|
159
|
-
parent
|
|
160
|
-
Navigator.parent_until_condition(parent, method(:usable_walk_parent?))
|
|
158
|
+
Navigator.parent_until_condition(article_tag.parent, ->(node) { usable_walk_parent?(node) })
|
|
161
159
|
end
|
|
162
160
|
|
|
163
161
|
def usable_walk_parent?(node)
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module Html
|
|
5
|
+
FeedLink = Data.define(:href, :mime_type)
|
|
6
|
+
|
|
5
7
|
# A native RSS/Atom hint from a document head +link[rel=alternate]+.
|
|
6
8
|
#
|
|
7
9
|
# Does not guess +/feed+ or +/rss.xml+ paths — that stays in configs +probe_rss+.
|
|
8
|
-
FeedLink
|
|
10
|
+
class FeedLink
|
|
9
11
|
class << self
|
|
10
12
|
##
|
|
11
13
|
# Collects RSS/Atom +rel=alternate+ links from +doc+'s +head+.
|
|
@@ -19,7 +19,7 @@ module Html2rss
|
|
|
19
19
|
# @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
|
|
20
20
|
# @return [String, nil] the concatenated visible text, or nil if none is found
|
|
21
21
|
def call(tag, separator: ' ', exclude_nodes: nil)
|
|
22
|
-
return tag.text.gsub(/\s+/, ' ').strip if tag.
|
|
22
|
+
return tag.text.gsub(/\s+/, ' ').strip if tag.is_a?(Nokogiri::XML::Text)
|
|
23
23
|
|
|
24
24
|
parts = iterate_children(tag, separator, exclude_nodes)
|
|
25
25
|
return if parts.empty?
|
|
@@ -6,16 +6,16 @@ module Html2rss
|
|
|
6
6
|
# Navigator owns DOM chrome recognition and node traversal helpers.
|
|
7
7
|
class Navigator
|
|
8
8
|
# Heading tags used to prioritize title extraction and container assessment.
|
|
9
|
-
HEADING_TAGS =
|
|
9
|
+
HEADING_TAGS = Set['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].freeze
|
|
10
10
|
|
|
11
11
|
# Element tags that indicate ignored DOM chrome when found in a container path.
|
|
12
|
-
IGNORED_CONTAINER_TAGS =
|
|
12
|
+
IGNORED_CONTAINER_TAGS = Set['nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
|
|
13
13
|
|
|
14
14
|
# Ancestor tags that usually indicate navigation/utility regions inside a content container.
|
|
15
|
-
UTILITY_LANDMARK_TAGS =
|
|
15
|
+
UTILITY_LANDMARK_TAGS = Set['nav', 'aside', 'footer', 'menu'].freeze
|
|
16
16
|
|
|
17
17
|
# Immediate parent walk stops here — not a usable article card.
|
|
18
|
-
CARD_WALK_STOP_TAGS = (UTILITY_LANDMARK_TAGS | IGNORED_CONTAINER_TAGS |
|
|
18
|
+
CARD_WALK_STOP_TAGS = (UTILITY_LANDMARK_TAGS | IGNORED_CONTAINER_TAGS | Set['html', 'body']).freeze
|
|
19
19
|
|
|
20
20
|
# Inner tags that mean a wrapping <a> is a card, not a span-styled list link.
|
|
21
21
|
WRAPPING_ANCHOR_CHILD_TAGS = (HEADING_TAGS + %w[p]).freeze
|
|
@@ -58,7 +58,7 @@ module Html2rss
|
|
|
58
58
|
# @param condition [Proc] The condition to be met.
|
|
59
59
|
# @return [Nokogiri::XML::Node, nil] The first parent that satisfies the condition.
|
|
60
60
|
def parent_until_condition(node, condition)
|
|
61
|
-
while node && !node.
|
|
61
|
+
while node && !node.is_a?(Nokogiri::XML::Document) && Probe.tag(node) != 'html'
|
|
62
62
|
return node if condition.call(node)
|
|
63
63
|
|
|
64
64
|
node = node.parent
|
|
@@ -72,7 +72,7 @@ module Html2rss
|
|
|
72
72
|
# @return [Boolean]
|
|
73
73
|
def usable_card_parent?(node)
|
|
74
74
|
return false unless node
|
|
75
|
-
return false if node.
|
|
75
|
+
return false if node.is_a?(Nokogiri::XML::Document)
|
|
76
76
|
|
|
77
77
|
!CARD_WALK_STOP_TAGS.include?(Probe.tag(node))
|
|
78
78
|
end
|
|
@@ -88,8 +88,7 @@ module Html2rss
|
|
|
88
88
|
while current_tag
|
|
89
89
|
found = current_tag.at_css(selector)
|
|
90
90
|
return found if found
|
|
91
|
-
|
|
92
|
-
return nil unless current_tag.respond_to?(:parent)
|
|
91
|
+
break if current_tag.is_a?(Nokogiri::XML::Document)
|
|
93
92
|
|
|
94
93
|
current_tag = current_tag.parent
|
|
95
94
|
end
|
|
@@ -115,11 +114,12 @@ module Html2rss
|
|
|
115
114
|
# @param parent_node [Nokogiri::XML::Node] potential ancestor
|
|
116
115
|
# @return [Boolean] true when child_node is a descendant of parent_node
|
|
117
116
|
def descendant_of?(child_node, parent_node)
|
|
118
|
-
curr = child_node.
|
|
117
|
+
curr = child_node.is_a?(Nokogiri::XML::Document) ? nil : child_node.parent
|
|
119
118
|
while curr
|
|
120
119
|
return true if curr == parent_node
|
|
120
|
+
break if curr.is_a?(Nokogiri::XML::Document)
|
|
121
121
|
|
|
122
|
-
curr = curr.
|
|
122
|
+
curr = curr.parent
|
|
123
123
|
end
|
|
124
124
|
false
|
|
125
125
|
end
|
|
@@ -81,7 +81,7 @@ module Html2rss
|
|
|
81
81
|
|
|
82
82
|
def processed_base_description
|
|
83
83
|
text = self.class.remove_pattern_from_start(@base, @title)
|
|
84
|
-
Html2rss::Selectors::PostProcessors::SanitizeHtml.
|
|
84
|
+
Html2rss::Selectors::PostProcessors::SanitizeHtml.call(text, @url)
|
|
85
85
|
end
|
|
86
86
|
end
|
|
87
87
|
end
|
|
@@ -12,9 +12,9 @@ module Html2rss
|
|
|
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
|
|
14
14
|
# Inline emphasis tags used as title fallbacks when no heading exists.
|
|
15
|
-
FALLBACK_HEADING_NAMES =
|
|
15
|
+
FALLBACK_HEADING_NAMES = Set[:strong, :b].freeze
|
|
16
16
|
# Nested blocks that mean a category node is actually a content container.
|
|
17
|
-
CATEGORY_CONTAINER_NAMES =
|
|
17
|
+
CATEGORY_CONTAINER_NAMES = Set[:p, :article, :section].freeze
|
|
18
18
|
|
|
19
19
|
class << self
|
|
20
20
|
##
|
|
@@ -175,10 +175,13 @@ module Html2rss
|
|
|
175
175
|
end
|
|
176
176
|
|
|
177
177
|
def descendant_of?(child, ancestor)
|
|
178
|
-
# Walk via root children since Extractor may not hold Index; use tag walk on tree.
|
|
179
178
|
return true if child.equal?(ancestor)
|
|
180
179
|
|
|
181
|
-
|
|
180
|
+
if (index = SST::Index.for_node(child))
|
|
181
|
+
return index.descendant_of?(child, ancestor)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
!ancestor.find { |d| d.equal?(child) }.nil?
|
|
182
185
|
end
|
|
183
186
|
|
|
184
187
|
def leftover_lines
|
|
@@ -220,7 +223,7 @@ module Html2rss
|
|
|
220
223
|
def immediate_card_parent
|
|
221
224
|
parent = sst_parent
|
|
222
225
|
index = SST::Index.for_node(@root)
|
|
223
|
-
index&.parent_until(parent,
|
|
226
|
+
index&.parent_until(parent, ->(node) { usable_walk_parent?(node) })
|
|
224
227
|
end
|
|
225
228
|
|
|
226
229
|
def usable_walk_parent?(node)
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module LinkDestination
|
|
5
|
-
# Normalized URL plus reusable route-classification facts for one link.
|
|
6
5
|
DestinationFacts = Data.define(
|
|
7
6
|
:url,
|
|
8
7
|
:destination,
|
|
@@ -15,7 +14,10 @@ module Html2rss
|
|
|
15
14
|
:strong_post_suffix,
|
|
16
15
|
:high_confidence_junk_path,
|
|
17
16
|
:high_confidence_utility_destination
|
|
18
|
-
)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Normalized URL plus reusable route-classification facts for one link.
|
|
20
|
+
class DestinationFacts
|
|
19
21
|
# @param url [Html2rss::Url] normalized destination URL
|
|
20
22
|
# @return [DestinationFacts] route facts for downstream link scoring
|
|
21
23
|
def self.build(url) # rubocop:disable Metrics/MethodLength
|
|
@@ -42,7 +42,16 @@ module Html2rss
|
|
|
42
42
|
destination_facts.utility_path &&
|
|
43
43
|
!destination_facts.content_path &&
|
|
44
44
|
!destination_facts.strong_post_suffix &&
|
|
45
|
-
text
|
|
45
|
+
word_count_at_most?(text, 3)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def word_count_at_most?(text, max)
|
|
49
|
+
count = 0
|
|
50
|
+
text.to_s.scan(/\p{Alnum}+/) do
|
|
51
|
+
count += 1
|
|
52
|
+
return false if count > max
|
|
53
|
+
end
|
|
54
|
+
true
|
|
46
55
|
end
|
|
47
56
|
|
|
48
57
|
def recommended_chrome?(text, destination_facts, heading_anchor:)
|