html2rss 0.32.4 → 0.33.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/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +7 -3
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
data/lib/html2rss/error.rb
CHANGED
|
@@ -40,10 +40,10 @@ module Html2rss
|
|
|
40
40
|
attr_accessor :transport_meta
|
|
41
41
|
|
|
42
42
|
# @return [String, nil] scrape-api timeout stage when known (overridden by RequestTimedOut)
|
|
43
|
-
def timeout_phase
|
|
43
|
+
def timeout_phase; end
|
|
44
44
|
|
|
45
45
|
# @return [Symbol, nil] surface classification when known (overridden by NoFeedItemsExtracted)
|
|
46
|
-
def surface_category
|
|
46
|
+
def surface_category; end
|
|
47
47
|
|
|
48
48
|
##
|
|
49
49
|
# Sparse telemetry hash for observability consumers.
|
|
@@ -59,6 +59,9 @@ module Html2rss
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Raised when a caller-supplied option value is malformed or out of range.
|
|
63
|
+
class InvalidOption < Error; end
|
|
64
|
+
|
|
62
65
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
63
66
|
class NoFeedItemsExtracted < Error
|
|
64
67
|
# Categories that append shared surface guidance to the empty-feed message.
|
|
@@ -87,7 +90,8 @@ module Html2rss
|
|
|
87
90
|
|
|
88
91
|
def base_message
|
|
89
92
|
summaries = attempts.map do |attempt|
|
|
90
|
-
|
|
93
|
+
count = attempt[:items_count]
|
|
94
|
+
details = count.nil? ? "#{attempt[:error_class]} error" : "#{count} items"
|
|
91
95
|
"#{attempt[:strategy]} (#{details})"
|
|
92
96
|
end.join(', ')
|
|
93
97
|
|
|
@@ -37,7 +37,7 @@ module Html2rss
|
|
|
37
37
|
item_maker.title = article.title
|
|
38
38
|
item_maker.author = article.author
|
|
39
39
|
description = ItemPresentation.description_for(article)
|
|
40
|
-
item_maker.description = description if description
|
|
40
|
+
item_maker.description = description if description
|
|
41
41
|
item_maker.link = article.url.to_s if article.url
|
|
42
42
|
item_maker.pubDate = article.published_at&.rfc2822
|
|
43
43
|
end
|
|
@@ -83,7 +83,7 @@ module Html2rss
|
|
|
83
83
|
# @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
|
|
84
84
|
# @return [void]
|
|
85
85
|
def record_error(strategy:, error:, elapsed_ms: nil)
|
|
86
|
-
meta = error.is_a?(
|
|
86
|
+
meta = error.transport_meta if error.is_a?(Error)
|
|
87
87
|
append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
|
|
88
88
|
@last_error = error
|
|
89
89
|
return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
@@ -137,7 +137,7 @@ module Html2rss
|
|
|
137
137
|
# @raise [Html2rss::Error, StandardError]
|
|
138
138
|
def raise_failure!
|
|
139
139
|
if (error = failure_error)
|
|
140
|
-
error.attempts = attempts if error.is_a?(
|
|
140
|
+
error.attempts = attempts if error.is_a?(Error)
|
|
141
141
|
raise error
|
|
142
142
|
end
|
|
143
143
|
|
|
@@ -215,7 +215,7 @@ module Html2rss
|
|
|
215
215
|
rescue *NON_FALLBACK_ERRORS => error
|
|
216
216
|
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
217
217
|
state.abort!
|
|
218
|
-
rescue
|
|
218
|
+
rescue Html2rss::Error => error
|
|
219
219
|
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
220
220
|
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy && request_session
|
|
221
221
|
end
|
|
@@ -50,7 +50,6 @@ module Html2rss
|
|
|
50
50
|
return unless parsed_html
|
|
51
51
|
|
|
52
52
|
url = Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).first
|
|
53
|
-
return unless url
|
|
54
53
|
return unless eligible_url?(url)
|
|
55
54
|
|
|
56
55
|
url
|
|
@@ -86,6 +85,8 @@ module Html2rss
|
|
|
86
85
|
return [] unless parsed_html
|
|
87
86
|
|
|
88
87
|
sst = SST::Normalizer.call(parsed_html)
|
|
88
|
+
return [] unless sst
|
|
89
|
+
|
|
89
90
|
link_resolver = Scoring::LinkResolver.new(entry_url)
|
|
90
91
|
|
|
91
92
|
SEGMENT_STRATEGIES.each do |strategy|
|
|
@@ -93,8 +94,6 @@ module Html2rss
|
|
|
93
94
|
return urls if urls.size >= 2
|
|
94
95
|
end
|
|
95
96
|
[]
|
|
96
|
-
rescue StandardError
|
|
97
|
-
[]
|
|
98
97
|
end
|
|
99
98
|
|
|
100
99
|
def segment_urls_for(sst, strategy, link_resolver)
|
|
@@ -120,7 +119,7 @@ module Html2rss
|
|
|
120
119
|
return if href.nil? || href.empty? || href.start_with?('#', 'mailto:', 'javascript:')
|
|
121
120
|
|
|
122
121
|
Html2rss::Url.from_relative(href, entry_url)
|
|
123
|
-
rescue
|
|
122
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
124
123
|
nil
|
|
125
124
|
end
|
|
126
125
|
|
|
@@ -8,15 +8,11 @@ module Html2rss
|
|
|
8
8
|
# Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
|
|
9
9
|
class Options
|
|
10
10
|
##
|
|
11
|
-
# @param auto_source [Hash, nil]
|
|
11
|
+
# @param auto_source [Hash, nil] validated +auto_source+ config
|
|
12
12
|
# @return [Options]
|
|
13
13
|
def self.from_auto_source(auto_source)
|
|
14
|
-
raw = auto_source
|
|
15
|
-
raw
|
|
16
|
-
new(
|
|
17
|
-
enabled: raw.fetch(:enabled, FeedResolution::DEFAULT_CONFIG[:enabled]) != false,
|
|
18
|
-
max_probes: Integer(raw.fetch(:max_probes, FeedResolution::DEFAULT_CONFIG[:max_probes]))
|
|
19
|
-
)
|
|
14
|
+
raw = auto_source&.dig(:entry_resolution) || {}
|
|
15
|
+
new(enabled: raw.fetch(:enabled, true), max_probes: raw.fetch(:max_probes, DEFAULT_CONFIG[:max_probes]))
|
|
20
16
|
end
|
|
21
17
|
|
|
22
18
|
##
|
|
@@ -8,40 +8,36 @@ module Html2rss
|
|
|
8
8
|
# Minimum admitted articles before the entry URL is considered strong enough.
|
|
9
9
|
ARTICLE_FLOOR = 3
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
|
|
41
|
-
native * 2 >= articles.size
|
|
11
|
+
class << self
|
|
12
|
+
##
|
|
13
|
+
# @param config [Html2rss::Config]
|
|
14
|
+
# @param articles [Array<Html2rss::Article>] typed extract results (count derived)
|
|
15
|
+
# @param surface_category [Html2rss::SurfaceCategory, Symbol]
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def resolve?(config:, articles:, surface_category:)
|
|
18
|
+
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
19
|
+
return false unless eligible_config?(config)
|
|
20
|
+
|
|
21
|
+
category = SurfaceCategory[surface_category]
|
|
22
|
+
return false if category.blocked?
|
|
23
|
+
|
|
24
|
+
articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def eligible_config?(config)
|
|
30
|
+
return false unless config.auto_source
|
|
31
|
+
return false if config.selectors
|
|
32
|
+
|
|
33
|
+
Options.from_auto_source(config.auto_source).enabled?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def native_feed_majority?(articles)
|
|
37
|
+
native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
|
|
38
|
+
native * 2 >= articles.size
|
|
39
|
+
end
|
|
42
40
|
end
|
|
43
|
-
module_function :native_feed_majority?
|
|
44
|
-
private_class_method :native_feed_majority?
|
|
45
41
|
end
|
|
46
42
|
end
|
|
47
43
|
end
|
|
@@ -16,7 +16,7 @@ module Html2rss
|
|
|
16
16
|
# @param origin_url [String, Html2rss::Url]
|
|
17
17
|
def initialize(request_session:, origin_url:)
|
|
18
18
|
@request_session = request_session
|
|
19
|
-
@origin_url =
|
|
19
|
+
@origin_url = Url.from_absolute(origin_url)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
##
|
|
@@ -28,7 +28,7 @@ module Html2rss
|
|
|
28
28
|
relation: :entry_resolution,
|
|
29
29
|
origin_url:
|
|
30
30
|
)
|
|
31
|
-
score_response(
|
|
31
|
+
score_response(response)
|
|
32
32
|
rescue Html2rss::Error
|
|
33
33
|
nil
|
|
34
34
|
end
|
|
@@ -37,7 +37,7 @@ module Html2rss
|
|
|
37
37
|
|
|
38
38
|
attr_reader :request_session, :origin_url
|
|
39
39
|
|
|
40
|
-
def score_response(
|
|
40
|
+
def score_response(response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
|
|
41
41
|
if response.feed_response?
|
|
42
42
|
count = Syndication::Parser.parse_response(response).size
|
|
43
43
|
return Scored.new(
|
|
@@ -11,38 +11,38 @@ module Html2rss
|
|
|
11
11
|
# Bonus when the surface looks like a listing rather than a hub/shell.
|
|
12
12
|
LISTING_SURFACE_BONUS = 5
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
class << self
|
|
15
|
+
##
|
|
16
|
+
# @param articles_count [Integer]
|
|
17
|
+
# @return [Integer]
|
|
18
|
+
def score_feed(articles_count:)
|
|
19
|
+
articles_count * ARTICLE_WEIGHT
|
|
20
|
+
end
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
score = assessment.articles_count * ARTICLE_WEIGHT
|
|
28
|
-
score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
|
|
29
|
-
drops = assessment.admission_drops.values.sum
|
|
30
|
-
total = assessment.articles_count + drops
|
|
31
|
-
score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
|
|
32
|
-
score
|
|
33
|
-
end
|
|
22
|
+
##
|
|
23
|
+
# @param assessment [Html2rss::PageRecon::Assessment]
|
|
24
|
+
# @return [Integer]
|
|
25
|
+
def score_assessment(assessment)
|
|
26
|
+
score = assessment.articles_count * ARTICLE_WEIGHT
|
|
27
|
+
score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
|
|
28
|
+
drops = assessment.admission_drops.values.sum
|
|
29
|
+
total = assessment.articles_count + drops
|
|
30
|
+
score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
|
|
31
|
+
score
|
|
32
|
+
end
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
##
|
|
35
|
+
# @param scored [Array<Probe::Scored>]
|
|
36
|
+
# @param entry_articles_count [Integer]
|
|
37
|
+
# @return [Probe::Scored, nil]
|
|
38
|
+
def pick_winner(scored:, entry_articles_count:)
|
|
39
|
+
winner = scored.max_by(&:score)
|
|
40
|
+
return unless winner
|
|
41
|
+
return if winner.articles_count <= entry_articles_count.to_i
|
|
42
|
+
return if winner.score <= 0
|
|
44
43
|
|
|
45
|
-
|
|
44
|
+
winner
|
|
45
|
+
end
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -116,17 +116,13 @@ module Html2rss
|
|
|
116
116
|
return unless resolution.applied
|
|
117
117
|
|
|
118
118
|
effective = scrape_target.with_effective(resolution.scrape_url)
|
|
119
|
-
|
|
120
|
-
resolution, effective:
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
nil
|
|
119
|
+
ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(effective:)
|
|
124
120
|
rescue RequestService::RequestBudgetExceeded
|
|
125
121
|
log_budget_exhausted
|
|
126
122
|
nil
|
|
127
123
|
rescue *NON_FALLBACK_ERRORS
|
|
128
124
|
raise
|
|
129
|
-
rescue
|
|
125
|
+
rescue Html2rss::Error => error
|
|
130
126
|
log_retry_failed(error)
|
|
131
127
|
nil
|
|
132
128
|
end
|
|
@@ -158,7 +154,7 @@ module Html2rss
|
|
|
158
154
|
# @return [String, nil]
|
|
159
155
|
def channel_host
|
|
160
156
|
Url.from_absolute(config.url.to_s).host
|
|
161
|
-
rescue
|
|
157
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
162
158
|
nil
|
|
163
159
|
end
|
|
164
160
|
|
|
@@ -184,7 +180,7 @@ module Html2rss
|
|
|
184
180
|
)
|
|
185
181
|
end
|
|
186
182
|
|
|
187
|
-
def retry_with(
|
|
183
|
+
def retry_with(effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
|
|
188
184
|
retry_session = pipeline.request_session_for(
|
|
189
185
|
config, strategy:, resources:, scrape_url: effective.effective_url
|
|
190
186
|
)
|
|
@@ -220,7 +216,7 @@ module Html2rss
|
|
|
220
216
|
def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
|
|
221
217
|
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
222
218
|
|
|
223
|
-
@entry_url =
|
|
219
|
+
@entry_url = Url.from_absolute(entry_url)
|
|
224
220
|
@response = response
|
|
225
221
|
@session = session
|
|
226
222
|
@config = config
|
data/lib/html2rss/feed_result.rb
CHANGED
|
@@ -23,8 +23,7 @@ module Html2rss
|
|
|
23
23
|
def initialize(channel:, articles:, status:, stylesheets: [])
|
|
24
24
|
raise ArgumentError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
|
|
25
25
|
raise ArgumentError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
|
|
26
|
-
|
|
27
|
-
articles = Array(articles)
|
|
26
|
+
raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
28
27
|
raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
|
|
29
28
|
|
|
30
29
|
@channel = channel
|
|
@@ -82,7 +81,9 @@ module Html2rss
|
|
|
82
81
|
end
|
|
83
82
|
|
|
84
83
|
def freeze_stylesheets(stylesheets)
|
|
85
|
-
Array(
|
|
84
|
+
raise ArgumentError, 'stylesheets must be an Array' unless stylesheets.is_a?(Array)
|
|
85
|
+
|
|
86
|
+
stylesheets.map do |sheet|
|
|
86
87
|
sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
|
|
87
88
|
value.is_a?(String) ? value.dup.freeze : value
|
|
88
89
|
end.freeze
|
data/lib/html2rss/hash_util.rb
CHANGED
|
@@ -3,116 +3,117 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
# Shared helpers for hash normalization and structural operations.
|
|
5
5
|
module HashUtil
|
|
6
|
-
|
|
6
|
+
class << self
|
|
7
|
+
# Deeply duplicates nested arrays and hashes.
|
|
8
|
+
#
|
|
9
|
+
# @param object [Object] nested value from configuration or runtime state
|
|
10
|
+
# @return [Object] deep duplicated object
|
|
11
|
+
def deep_dup(object)
|
|
12
|
+
case object
|
|
13
|
+
in Hash
|
|
14
|
+
object.transform_values { deep_dup(_1) }
|
|
15
|
+
in Array
|
|
16
|
+
object.map { deep_dup(_1) }
|
|
17
|
+
else
|
|
18
|
+
object.dup
|
|
19
|
+
end
|
|
20
|
+
end
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
# Deeply merges nested hashes while replacing non-hash values from override.
|
|
23
|
+
#
|
|
24
|
+
# @param base [Hash] base hash
|
|
25
|
+
# @param override [Hash] override hash
|
|
26
|
+
# @return [Hash] merged hash
|
|
27
|
+
def deep_merge(base, override)
|
|
28
|
+
base.merge(override) do |_key, old_val, new_val|
|
|
29
|
+
case [old_val, new_val]
|
|
30
|
+
in [Hash, Hash]
|
|
31
|
+
deep_merge(old_val, new_val)
|
|
32
|
+
else
|
|
33
|
+
new_val
|
|
34
|
+
end
|
|
35
|
+
end
|
|
20
36
|
end
|
|
21
|
-
end
|
|
22
37
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
# Converts string-keyed hashes to symbol-keyed hashes recursively.
|
|
39
|
+
#
|
|
40
|
+
# @param object [Object] value to normalize
|
|
41
|
+
# @param context [String] error context
|
|
42
|
+
# @return [Object] normalized value
|
|
43
|
+
def deep_symbolize_keys(object, context: 'hash')
|
|
44
|
+
case object
|
|
45
|
+
in Hash
|
|
46
|
+
object.each_with_object({}) do |(k, v), memo|
|
|
47
|
+
memo[symbol_key(k, context:)] = deep_symbolize_keys(v, context:)
|
|
48
|
+
end
|
|
49
|
+
in Array
|
|
50
|
+
object.map { deep_symbolize_keys(_1, context:) }
|
|
33
51
|
else
|
|
34
|
-
|
|
52
|
+
object
|
|
35
53
|
end
|
|
36
54
|
end
|
|
37
|
-
end
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
# Converts hash keys (and Symbol values) to strings recursively.
|
|
57
|
+
#
|
|
58
|
+
# @param object [Object] value to normalize
|
|
59
|
+
# @return [Object] normalized value with string keys
|
|
60
|
+
def deep_stringify_keys(object)
|
|
61
|
+
case object
|
|
62
|
+
when Hash
|
|
63
|
+
object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
|
|
64
|
+
when Array
|
|
65
|
+
object.map { deep_stringify_keys(_1) }
|
|
66
|
+
when Symbol
|
|
67
|
+
object.to_s
|
|
68
|
+
else
|
|
69
|
+
object
|
|
49
70
|
end
|
|
50
|
-
in Array
|
|
51
|
-
object.map { deep_symbolize_keys(_1, context:) }
|
|
52
|
-
else
|
|
53
|
-
object
|
|
54
71
|
end
|
|
55
|
-
end
|
|
56
72
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
when Array
|
|
66
|
-
object.map { deep_stringify_keys(_1) }
|
|
67
|
-
when Symbol
|
|
68
|
-
object.to_s
|
|
69
|
-
else
|
|
70
|
-
object
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
+
# Validates that hash keys are symbols.
|
|
74
|
+
#
|
|
75
|
+
# @param value [Object] candidate hash container whose keys must be symbols
|
|
76
|
+
# @param context [String] error context
|
|
77
|
+
# @param deep [Boolean] whether nested hashes should also be validated
|
|
78
|
+
# @return [void]
|
|
79
|
+
def assert_symbol_keys!(value, context: 'hash', deep: true)
|
|
80
|
+
return unless value in Hash
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# @param deep [Boolean] whether nested hashes should also be validated
|
|
79
|
-
# @return [void]
|
|
80
|
-
def assert_symbol_keys!(value, context: 'hash', deep: true)
|
|
81
|
-
return unless value in Hash
|
|
82
|
+
unless value.each_key.all?(Symbol)
|
|
83
|
+
invalid_key = value.keys.find { _1.class != Symbol }
|
|
84
|
+
raise ArgumentError, "#{context} must use symbol keys (found #{invalid_key.inspect})"
|
|
85
|
+
end
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
invalid_key = value.keys.find { _1.class != Symbol }
|
|
85
|
-
raise ArgumentError, "#{context} must use symbol keys (found #{invalid_key.inspect})"
|
|
87
|
+
value.each_value { assert_symbol_keys!(_1, context:, deep:) } if deep
|
|
86
88
|
end
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
# Validates that hash keys are strings.
|
|
91
|
+
#
|
|
92
|
+
# @param value [Object] candidate hash container whose keys must be strings
|
|
93
|
+
# @param context [String] error context
|
|
94
|
+
# @param deep [Boolean] whether nested hashes should also be validated
|
|
95
|
+
# @return [void]
|
|
96
|
+
def assert_string_keys!(value, context: 'hash', deep: true)
|
|
97
|
+
return unless value in Hash
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# @param deep [Boolean] whether nested hashes should also be validated
|
|
96
|
-
# @return [void]
|
|
97
|
-
def assert_string_keys!(value, context: 'hash', deep: true)
|
|
98
|
-
return unless value in Hash
|
|
99
|
+
unless value.each_key.all?(String)
|
|
100
|
+
invalid_key = value.keys.find { _1.class != String }
|
|
101
|
+
raise ArgumentError, "#{context} must use string keys (found #{invalid_key.inspect})"
|
|
102
|
+
end
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
invalid_key = value.keys.find { _1.class != String }
|
|
102
|
-
raise ArgumentError, "#{context} must use string keys (found #{invalid_key.inspect})"
|
|
104
|
+
value.each_value { assert_string_keys!(_1, context:, deep:) } if deep
|
|
103
105
|
end
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
end
|
|
107
|
+
private
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
def symbol_key(key, context:)
|
|
110
|
+
case key
|
|
111
|
+
in Symbol then key
|
|
112
|
+
in String then key.to_sym
|
|
113
|
+
else
|
|
114
|
+
raise ArgumentError, "#{context} must use string or symbol keys (found #{key.inspect})"
|
|
115
|
+
end
|
|
114
116
|
end
|
|
115
117
|
end
|
|
116
|
-
private_class_method :symbol_key
|
|
117
118
|
end
|
|
118
119
|
end
|
|
@@ -28,23 +28,20 @@ module Html2rss
|
|
|
28
28
|
def self.call(article_tag, title: nil)
|
|
29
29
|
return [] unless article_tag
|
|
30
30
|
|
|
31
|
-
extract_all_categories(article_tag, title:)
|
|
32
|
-
.map(&:strip)
|
|
33
|
-
.reject(&:empty?)
|
|
31
|
+
extract_all_categories(article_tag, title:).to_a
|
|
34
32
|
end
|
|
35
33
|
|
|
36
34
|
##
|
|
37
35
|
# Optimized single DOM traversal that extracts all category types.
|
|
36
|
+
# `Node#css` only yields descendants, so the container itself is never visited.
|
|
38
37
|
#
|
|
39
38
|
# @param article_tag [Nokogiri::XML::Element] The article element
|
|
40
39
|
# @param title [String, nil]
|
|
41
40
|
# @return [Set<String>] Set of category strings
|
|
42
|
-
def self.extract_all_categories(article_tag, title:
|
|
41
|
+
def self.extract_all_categories(article_tag, title:)
|
|
43
42
|
Set.new.tap do |categories|
|
|
44
43
|
extract_element_data_categories!(categories, article_tag, title:)
|
|
45
44
|
article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
|
|
46
|
-
next if element == article_tag
|
|
47
|
-
|
|
48
45
|
if ArticleRules::Category.class_match?(element['class'])
|
|
49
46
|
extract_text_categories!(categories, element, title:)
|
|
50
47
|
end
|
|
@@ -60,7 +57,7 @@ module Html2rss
|
|
|
60
57
|
# @param element [Nokogiri::XML::Element] metadata element that may contain category links
|
|
61
58
|
# @param title [String, nil]
|
|
62
59
|
# @return [void]
|
|
63
|
-
def self.extract_element_data_categories!(categories, element, title:
|
|
60
|
+
def self.extract_element_data_categories!(categories, element, title:)
|
|
64
61
|
element.attributes.each_value do |attr|
|
|
65
62
|
next unless ArticleRules::Category.attr_name_match?(attr.name)
|
|
66
63
|
|
|
@@ -75,7 +72,7 @@ module Html2rss
|
|
|
75
72
|
# @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
|
|
76
73
|
# @param title [String, nil]
|
|
77
74
|
# @return [void]
|
|
78
|
-
def self.extract_text_categories!(categories, element, title:
|
|
75
|
+
def self.extract_text_categories!(categories, element, title:)
|
|
79
76
|
return if category_container?(element)
|
|
80
77
|
|
|
81
78
|
anchors = element.name == 'a' ? [element] : element.css('a').to_a
|
|
@@ -96,7 +93,7 @@ module Html2rss
|
|
|
96
93
|
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
97
94
|
# @param title [String, nil]
|
|
98
95
|
# @return [void]
|
|
99
|
-
def self.add_text_to_categories!(categories, element, title:
|
|
96
|
+
def self.add_text_to_categories!(categories, element, title:)
|
|
100
97
|
ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element), title:)
|
|
101
98
|
end
|
|
102
99
|
|
|
@@ -9,7 +9,7 @@ module Html2rss
|
|
|
9
9
|
# @param leftover_lines [Array<String>] leftover visible lines (already split)
|
|
10
10
|
# @param time_zone [String] channel time zone for naive values
|
|
11
11
|
# @return [DateTime, nil]
|
|
12
|
-
def self.call(article_tag, leftover_lines: []
|
|
12
|
+
def self.call(article_tag, time_zone:, leftover_lines: [])
|
|
13
13
|
datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
|
|
14
14
|
ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
|
|
15
15
|
end
|
|
@@ -23,8 +23,6 @@ module Html2rss
|
|
|
23
23
|
# @param base_url [String, Html2rss::Url] base URL for relative enclosure links
|
|
24
24
|
# @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
|
|
25
25
|
def self.call(article_tag, base_url)
|
|
26
|
-
return [] unless article_tag
|
|
27
|
-
|
|
28
26
|
article_tag.css(SELECTOR).filter_map do |element|
|
|
29
27
|
extract_from_element(element, base_url)
|
|
30
28
|
end
|
|
@@ -34,7 +32,7 @@ module Html2rss
|
|
|
34
32
|
case element.name
|
|
35
33
|
when 'img'
|
|
36
34
|
ArticleRules::Enclosure.from_image(element['src'], base_url)
|
|
37
|
-
when '
|
|
35
|
+
when 'audio', 'source'
|
|
38
36
|
ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
|
|
39
37
|
when 'iframe'
|
|
40
38
|
ArticleRules::Enclosure.from_iframe(element['src'], base_url)
|