html2rss 0.33.0 → 0.34.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 +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -21
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +126 -50
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +49 -6
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +4 -0
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +100 -9
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +0 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +6 -6
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors.rb +53 -12
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -4,61 +4,68 @@ require 'dry-validation'
|
|
|
4
4
|
|
|
5
5
|
module Html2rss
|
|
6
6
|
class Config
|
|
7
|
+
# Scraper options are splatted into scraper constructors, so a key no scraper accepts is a
|
|
8
|
+
# validation error. Key validation applies only when a schema is called directly, so the
|
|
9
|
+
# auto-source contract keeps +scraper+ an open hash and {Validator} applies this one to it.
|
|
10
|
+
# The raw values reach the scrapers, so this schema checks types without coercing them.
|
|
11
|
+
AutoSourceScraperContract = Dry::Schema.JSON do # rubocop:disable Metrics/BlockLength
|
|
12
|
+
config.validate_keys = true
|
|
13
|
+
|
|
14
|
+
optional(:native_feed).hash do
|
|
15
|
+
optional(:enabled).filled(:bool)
|
|
16
|
+
end
|
|
17
|
+
optional(:wordpress_api).hash do
|
|
18
|
+
optional(:enabled).filled(:bool)
|
|
19
|
+
end
|
|
20
|
+
optional(:sitemap).hash do
|
|
21
|
+
optional(:enabled).filled(:bool)
|
|
22
|
+
optional(:min_priority).filled { int? | float? }
|
|
23
|
+
optional(:max_age_days).filled(:integer, gt?: 0)
|
|
24
|
+
end
|
|
25
|
+
optional(:schema).hash do
|
|
26
|
+
optional(:enabled).filled(:bool)
|
|
27
|
+
end
|
|
28
|
+
optional(:microdata).hash do
|
|
29
|
+
optional(:enabled).filled(:bool)
|
|
30
|
+
end
|
|
31
|
+
optional(:microformats2).hash do
|
|
32
|
+
optional(:enabled).filled(:bool)
|
|
33
|
+
end
|
|
34
|
+
optional(:json_state).hash do
|
|
35
|
+
optional(:enabled).filled(:bool)
|
|
36
|
+
end
|
|
37
|
+
optional(:json_document).hash do
|
|
38
|
+
optional(:enabled).filled(:bool)
|
|
39
|
+
end
|
|
40
|
+
optional(:xhr_articles).hash do
|
|
41
|
+
optional(:enabled).filled(:bool)
|
|
42
|
+
end
|
|
43
|
+
optional(:meta_oembed).hash do
|
|
44
|
+
optional(:enabled).filled(:bool)
|
|
45
|
+
end
|
|
46
|
+
optional(:semantic_html).hash do
|
|
47
|
+
optional(:enabled).filled(:bool)
|
|
48
|
+
end
|
|
49
|
+
optional(:html).hash do
|
|
50
|
+
optional(:enabled).filled(:bool)
|
|
51
|
+
optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
|
|
52
|
+
optional(:use_top_selectors).filled(:integer, gt?: 0)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
7
56
|
# Runtime source of truth for validating auto-source config values.
|
|
8
|
-
AutoSourceContract = Dry::Schema.Params do
|
|
57
|
+
AutoSourceContract = Dry::Schema.Params do
|
|
9
58
|
optional(:limit).filled(:integer, gt?: 0)
|
|
10
59
|
optional(:entry_resolution).hash do
|
|
11
60
|
optional(:enabled).filled(:bool)
|
|
12
61
|
optional(:max_probes).filled(:integer, gt?: 0)
|
|
13
62
|
end
|
|
14
63
|
|
|
15
|
-
optional(:scraper).hash
|
|
16
|
-
optional(:native_feed).hash do
|
|
17
|
-
optional(:enabled).filled(:bool)
|
|
18
|
-
end
|
|
19
|
-
optional(:wordpress_api).hash do
|
|
20
|
-
optional(:enabled).filled(:bool)
|
|
21
|
-
end
|
|
22
|
-
optional(:sitemap).hash do
|
|
23
|
-
optional(:enabled).filled(:bool)
|
|
24
|
-
optional(:min_priority).filled(:float)
|
|
25
|
-
optional(:max_age_days).filled(:integer, gt?: 0)
|
|
26
|
-
end
|
|
27
|
-
optional(:schema).hash do
|
|
28
|
-
optional(:enabled).filled(:bool)
|
|
29
|
-
end
|
|
30
|
-
optional(:microdata).hash do
|
|
31
|
-
optional(:enabled).filled(:bool)
|
|
32
|
-
end
|
|
33
|
-
optional(:microformats2).hash do
|
|
34
|
-
optional(:enabled).filled(:bool)
|
|
35
|
-
end
|
|
36
|
-
optional(:json_state).hash do
|
|
37
|
-
optional(:enabled).filled(:bool)
|
|
38
|
-
end
|
|
39
|
-
optional(:json_document).hash do
|
|
40
|
-
optional(:enabled).filled(:bool)
|
|
41
|
-
end
|
|
42
|
-
optional(:xhr_articles).hash do
|
|
43
|
-
optional(:enabled).filled(:bool)
|
|
44
|
-
end
|
|
45
|
-
optional(:meta_oembed).hash do
|
|
46
|
-
optional(:enabled).filled(:bool)
|
|
47
|
-
end
|
|
48
|
-
optional(:semantic_html).hash do
|
|
49
|
-
optional(:enabled).filled(:bool)
|
|
50
|
-
optional(:fallback_anchorless).filled(:bool)
|
|
51
|
-
end
|
|
52
|
-
optional(:html).hash do
|
|
53
|
-
optional(:enabled).filled(:bool)
|
|
54
|
-
optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
|
|
55
|
-
optional(:use_top_selectors).filled(:integer, gt?: 0)
|
|
56
|
-
optional(:fallback_anchorless).filled(:bool)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
64
|
+
optional(:scraper).hash
|
|
59
65
|
|
|
60
66
|
optional(:cleanup).hash do
|
|
61
67
|
optional(:keep_different_domain).filled(:bool)
|
|
68
|
+
optional(:keep_sponsored).filled(:bool)
|
|
62
69
|
end
|
|
63
70
|
end
|
|
64
71
|
end
|
|
@@ -6,6 +6,9 @@ module Html2rss
|
|
|
6
6
|
# Maps internal Dry::Validation messages to {ValidationIssue} (predicate → code).
|
|
7
7
|
# Not a public API — Dry stays inside Validator / this mapper.
|
|
8
8
|
module IssueMapper
|
|
9
|
+
# Raised when the schema yields a Dry predicate this mapper has no code for (a mapping-table gap).
|
|
10
|
+
class InvalidError < Html2rss::ContractError; end
|
|
11
|
+
|
|
9
12
|
# Dry schema predicates → closed {ValidationIssue::CODES}.
|
|
10
13
|
PREDICATE_CODES = {
|
|
11
14
|
key?: :missing_key,
|
|
@@ -65,7 +68,7 @@ module Html2rss
|
|
|
65
68
|
return :invalid_value if predicate.nil?
|
|
66
69
|
|
|
67
70
|
PREDICATE_CODES.fetch(predicate) do
|
|
68
|
-
raise
|
|
71
|
+
raise InvalidError, "unmapped Dry validation predicate: #{predicate.inspect}"
|
|
69
72
|
end
|
|
70
73
|
end
|
|
71
74
|
|
|
@@ -50,15 +50,18 @@ module Html2rss
|
|
|
50
50
|
# @param strategy [Symbol, nil]
|
|
51
51
|
# @param max_redirects [Integer, nil]
|
|
52
52
|
# @param max_requests [Integer, nil]
|
|
53
|
+
# @param total_timeout_seconds [Integer, nil] wall-clock bound for the whole request chain
|
|
53
54
|
# @return [RequestControls]
|
|
54
|
-
def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil)
|
|
55
|
+
def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil, total_timeout_seconds: nil)
|
|
55
56
|
strategy = Option.strategy(strategy) unless strategy.nil?
|
|
56
57
|
check_budgets!(max_redirects, max_requests)
|
|
58
|
+
Option.integer(:total_timeout_seconds, total_timeout_seconds, min: 1, optional: true)
|
|
57
59
|
keys = []
|
|
58
60
|
keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
|
|
59
61
|
keys << :max_redirects unless max_redirects.nil?
|
|
60
62
|
keys << :max_requests unless max_requests.nil?
|
|
61
|
-
|
|
63
|
+
keys << :total_timeout_seconds unless total_timeout_seconds.nil?
|
|
64
|
+
new(strategy:, max_redirects:, max_requests:, total_timeout_seconds:, explicit_keys: keys)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
##
|
|
@@ -170,7 +173,7 @@ module Html2rss
|
|
|
170
173
|
return config[:request] = {} if request_config.nil?
|
|
171
174
|
return if request_config.is_a?(Hash)
|
|
172
175
|
|
|
173
|
-
raise
|
|
176
|
+
raise InvalidOption, 'request config must be a hash'
|
|
174
177
|
end
|
|
175
178
|
end
|
|
176
179
|
end
|
|
@@ -105,6 +105,17 @@ module Html2rss
|
|
|
105
105
|
request[:botasaurus] = exported
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
# Scraper options are splatted into scraper constructors, so the scraper map and each
|
|
109
|
+
# scraper object reject keys the contract does not name.
|
|
110
|
+
def auto_source_schema
|
|
111
|
+
schema = Html2rss::Config::AutoSourceContract.json_schema(loose: true)
|
|
112
|
+
scraper = Html2rss::Config::AutoSourceScraperContract.json_schema(loose: true).except(:$schema)
|
|
113
|
+
scraper[:additionalProperties] = false
|
|
114
|
+
scraper.fetch(:properties).each_value { |options| options[:additionalProperties] = false }
|
|
115
|
+
schema.fetch(:properties)[:scraper] = scraper
|
|
116
|
+
schema.merge(default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG))
|
|
117
|
+
end
|
|
118
|
+
|
|
108
119
|
# @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
|
|
109
120
|
def registry_catalog_defs
|
|
110
121
|
{
|
|
@@ -127,8 +138,9 @@ module Html2rss
|
|
|
127
138
|
default: Html2rss::Selectors::DEFAULT_ITEMS_SELECTOR
|
|
128
139
|
)
|
|
129
140
|
items_schema[:properties][:enhance] = items_schema.fetch(:properties).fetch(:enhance).merge(
|
|
130
|
-
description: '
|
|
131
|
-
'
|
|
141
|
+
description: 'Fills missing title, link, description, date, image and categories from each ' \
|
|
142
|
+
"item's own markup via Html::ArticleExtractor. Explicit selectors win; no extra " \
|
|
143
|
+
'requests are made.'
|
|
132
144
|
)
|
|
133
145
|
items_schema[:properties][:pagination] = {
|
|
134
146
|
description: 'Pagination configuration or maximum page count integer.',
|
|
@@ -175,9 +187,7 @@ module Html2rss
|
|
|
175
187
|
description: 'Collection of stylesheets to attach to the RSS feed.',
|
|
176
188
|
items: Html2rss::Config::Validator::StylesheetConfig.json_schema(loose: true)
|
|
177
189
|
},
|
|
178
|
-
auto_source:
|
|
179
|
-
default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG)
|
|
180
|
-
),
|
|
190
|
+
auto_source: auto_source_schema,
|
|
181
191
|
selectors: {
|
|
182
192
|
type: 'object',
|
|
183
193
|
description: 'Selectors used to extract article attributes.',
|
|
@@ -142,7 +142,7 @@ module Html2rss
|
|
|
142
142
|
end
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
-
# Same compile as Gsub#call.
|
|
145
|
+
# Same compile as Gsub#call. InvalidOption is the bound; Gsub maps
|
|
146
146
|
# parser errors onto that path so admit and execute still share it.
|
|
147
147
|
#
|
|
148
148
|
# @param klass [Class]
|
|
@@ -156,7 +156,7 @@ module Html2rss
|
|
|
156
156
|
|
|
157
157
|
Selectors::PostProcessors::Gsub.compiled_pattern(pattern)
|
|
158
158
|
nil
|
|
159
|
-
rescue
|
|
159
|
+
rescue InvalidOption => error
|
|
160
160
|
error.message
|
|
161
161
|
end
|
|
162
162
|
end
|
|
@@ -10,6 +10,9 @@ module Html2rss
|
|
|
10
10
|
# +path+ is JSON-pointer-friendly segments. +code+ is closed ({CODES}).
|
|
11
11
|
# +expected+ / +actual+ are JSON-ish or +nil+ (never invented).
|
|
12
12
|
class ValidationIssue
|
|
13
|
+
# Raised when an issue is built with a code outside {CODES}.
|
|
14
|
+
class InvalidError < Html2rss::ContractError; end
|
|
15
|
+
|
|
13
16
|
# Closed set of issue codes (mapper owns Dry predicate → code).
|
|
14
17
|
CODES = Set[
|
|
15
18
|
:missing_key,
|
|
@@ -27,7 +30,7 @@ module Html2rss
|
|
|
27
30
|
# @param expected [Hash, String, Numeric, true, false, nil]
|
|
28
31
|
# @param actual [Hash, String, Numeric, true, false, nil]
|
|
29
32
|
def initialize(path:, code:, message:, expected: nil, actual: nil)
|
|
30
|
-
raise
|
|
33
|
+
raise InvalidError, "unknown ValidationIssue code: #{code.inspect}" unless CODES.include?(code)
|
|
31
34
|
|
|
32
35
|
super(
|
|
33
36
|
path: Array(path).freeze,
|
|
@@ -153,6 +153,17 @@ module Html2rss
|
|
|
153
153
|
end
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
+
rule(auto_source: :scraper) do
|
|
157
|
+
next unless value
|
|
158
|
+
|
|
159
|
+
result = Config::AutoSourceScraperContract.call(value)
|
|
160
|
+
next if result.success?
|
|
161
|
+
|
|
162
|
+
result.errors.each do |error|
|
|
163
|
+
key([:auto_source, :scraper, *error.path]).failure(error.text)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
156
167
|
# URL validation delegated to Url class
|
|
157
168
|
rule(:channel) do
|
|
158
169
|
if (url_string = values.dig(:channel, :url)) && !url_string.empty?
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
#
|
|
12
12
|
# Configuration is validated during initialization.
|
|
13
13
|
class Config # rubocop:disable Metrics/ClassLength
|
|
14
|
-
# Raised when a configuration
|
|
14
|
+
# Raised when a configuration cannot be loaded (YAML shape, file, feed name) or fails runtime validation.
|
|
15
15
|
class InvalidConfig < Html2rss::Error; end
|
|
16
16
|
|
|
17
17
|
class << self
|
|
@@ -25,7 +25,7 @@ module Html2rss
|
|
|
25
25
|
# @return [Array(Hash, Html2rss::Config::ValidationReport)]
|
|
26
26
|
def resolve_and_validate(config_input, feed_name: nil, params: {})
|
|
27
27
|
raw = resolve_raw_hash(config_input, feed_name)
|
|
28
|
-
rescue
|
|
28
|
+
rescue InvalidConfig => error
|
|
29
29
|
[{}, IssueMapper.parse_failure(error.message)]
|
|
30
30
|
else
|
|
31
31
|
working = HashUtil.deep_dup(raw)
|
|
@@ -66,14 +66,16 @@ module Html2rss
|
|
|
66
66
|
#
|
|
67
67
|
# @param string [String] YAML document
|
|
68
68
|
# @return [Hash{Symbol => Object}] configuration hash
|
|
69
|
-
# @raise [
|
|
69
|
+
# @raise [InvalidConfig] if +string+ is not a String or does not deserialize to a Hash
|
|
70
70
|
def from_yaml(string)
|
|
71
|
-
raise
|
|
71
|
+
raise InvalidConfig, 'YAML must be a String' unless string.is_a?(String)
|
|
72
72
|
|
|
73
73
|
parsed = YAML.safe_load(string)
|
|
74
|
-
raise
|
|
74
|
+
raise InvalidConfig, 'YAML must deserialize to a Hash' unless parsed.is_a?(Hash)
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
symbolize_config(parsed)
|
|
77
|
+
rescue Psych::Exception => error
|
|
78
|
+
raise InvalidConfig, "YAML could not be parsed: #{error.message}"
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
##
|
|
@@ -85,25 +87,16 @@ module Html2rss
|
|
|
85
87
|
# @param feed_name [String, nil] the feed name when using multiple feeds.
|
|
86
88
|
# @param multiple_feeds_key [Symbol] the key under which multiple feeds are defined.
|
|
87
89
|
# @return [Hash{Symbol => Object}] the configuration hash.
|
|
88
|
-
# @raise [
|
|
89
|
-
# rubocop:disable-next Metrics/MethodLength
|
|
90
|
+
# @raise [InvalidConfig] if the file doesn't exist or feed is not found.
|
|
90
91
|
def load_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS)
|
|
91
|
-
raise
|
|
92
|
-
raise
|
|
92
|
+
raise InvalidConfig, "File '#{file}' does not exist" unless File.exist?(file)
|
|
93
|
+
raise InvalidConfig, "`#{multiple_feeds_key}` is a reserved feed name" if feed_name == multiple_feeds_key
|
|
93
94
|
|
|
94
|
-
yaml =
|
|
95
|
+
yaml = read_yaml_mapping(file)
|
|
95
96
|
|
|
96
97
|
return yaml unless yaml.key?(multiple_feeds_key)
|
|
97
98
|
|
|
98
|
-
|
|
99
|
-
available_feeds = yaml.fetch(multiple_feeds_key).keys.join(', ')
|
|
100
|
-
raise ArgumentError,
|
|
101
|
-
"Feed name is required under `#{multiple_feeds_key}`. Available feeds: #{available_feeds}"
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
config = yaml.dig(multiple_feeds_key, feed_name.to_sym)
|
|
105
|
-
raise ArgumentError, "Feed '#{feed_name}' not found under `#{multiple_feeds_key}` key." unless config
|
|
106
|
-
|
|
99
|
+
config = select_feed(file, feed_name, yaml.fetch(multiple_feeds_key), multiple_feeds_key)
|
|
107
100
|
MultipleFeedsConfig.to_single_feed(config, yaml, multiple_feeds_key:)
|
|
108
101
|
end
|
|
109
102
|
|
|
@@ -126,17 +119,15 @@ module Html2rss
|
|
|
126
119
|
# @param request_controls [Html2rss::Config::RequestControls, nil] explicit request controls to write
|
|
127
120
|
# @param limit [Integer, nil] max articles to keep in the auto-sourced feed
|
|
128
121
|
# @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
|
|
122
|
+
# @param keep_sponsored [Boolean, nil] when set, overrides cleanup.keep_sponsored
|
|
129
123
|
# @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
|
|
130
|
-
# rubocop:disable-next Metrics/MethodLength --
|
|
124
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- cleanup overrides stay co-located
|
|
131
125
|
def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil,
|
|
132
|
-
keep_different_domain: nil)
|
|
126
|
+
keep_different_domain: nil, keep_sponsored: nil)
|
|
133
127
|
auto_source = AutoSource::DEFAULT_CONFIG
|
|
134
128
|
auto_source = auto_source.merge(limit: Option.integer(:limit, limit, min: 1)) unless limit.nil?
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
cleanup: auto_source[:cleanup].merge(keep_different_domain:)
|
|
138
|
-
)
|
|
139
|
-
end
|
|
129
|
+
cleanup = { keep_different_domain:, keep_sponsored: }.compact
|
|
130
|
+
auto_source = auto_source.merge(cleanup: auto_source[:cleanup].merge(cleanup)) unless cleanup.empty?
|
|
140
131
|
|
|
141
132
|
config = {
|
|
142
133
|
channel: default_config[:channel].merge(url:),
|
|
@@ -228,6 +219,41 @@ module Html2rss
|
|
|
228
219
|
end
|
|
229
220
|
end
|
|
230
221
|
private :resolve_raw_hash
|
|
222
|
+
|
|
223
|
+
def read_yaml_mapping(file)
|
|
224
|
+
yaml = YAML.safe_load_file(file)
|
|
225
|
+
return symbolize_config(yaml) if yaml.is_a?(Hash)
|
|
226
|
+
|
|
227
|
+
raise InvalidConfig, "File '#{file}' must contain a YAML mapping (a Hash)"
|
|
228
|
+
rescue Psych::Exception => error
|
|
229
|
+
raise InvalidConfig, "File '#{file}' could not be parsed: #{error.message}"
|
|
230
|
+
end
|
|
231
|
+
private :read_yaml_mapping
|
|
232
|
+
|
|
233
|
+
# @return [Hash{Symbol => Object}] the named feed's config from the +feeds+ mapping
|
|
234
|
+
def select_feed(file, feed_name, feeds, key)
|
|
235
|
+
unless feeds.is_a?(Hash)
|
|
236
|
+
raise InvalidConfig, "File '#{file}': `#{key}` must be a mapping of feed names to configs"
|
|
237
|
+
end
|
|
238
|
+
unless feed_name
|
|
239
|
+
raise InvalidConfig, "Feed name is required under `#{key}`. Available feeds: #{feeds.keys.join(', ')}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
config = feeds[feed_name.to_sym]
|
|
243
|
+
raise InvalidConfig, "Feed '#{feed_name}' not found under `#{key}` key." unless config
|
|
244
|
+
raise InvalidConfig, "File '#{file}': feed '#{feed_name}' must be a mapping" unless config.is_a?(Hash)
|
|
245
|
+
|
|
246
|
+
config
|
|
247
|
+
end
|
|
248
|
+
private :select_feed
|
|
249
|
+
|
|
250
|
+
# Loaded config keys that cannot be symbolized (non-string, non-symbol) are a config-load failure.
|
|
251
|
+
def symbolize_config(parsed)
|
|
252
|
+
HashUtil.deep_symbolize_keys(parsed, context: 'config')
|
|
253
|
+
rescue InvalidOption => error
|
|
254
|
+
raise InvalidConfig, error.message
|
|
255
|
+
end
|
|
256
|
+
private :symbolize_config
|
|
231
257
|
end
|
|
232
258
|
|
|
233
259
|
##
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Base for programmer errors: a value object built from wrong parts (each owner's +InvalidError+) or a
|
|
6
|
+
# component breaking its contract ({AutoSource::ScraperContractError}). It is an {Error}, but no
|
|
7
|
+
# operational rescue may swallow it: every broad +rescue Html2rss::Error+ in +lib/+ re-raises it first.
|
|
8
|
+
class ContractError < Error; end
|
|
9
|
+
end
|
data/lib/html2rss/defaults.rb
CHANGED
|
@@ -55,9 +55,9 @@ module Html2rss
|
|
|
55
55
|
#
|
|
56
56
|
# @param logger [Logger]
|
|
57
57
|
# @return [Logger] the logger
|
|
58
|
-
# @raise [
|
|
58
|
+
# @raise [InvalidOption] if logger is not a Logger
|
|
59
59
|
def logger=(logger)
|
|
60
|
-
raise
|
|
60
|
+
raise InvalidOption, 'logger must be a Logger' unless logger.is_a?(Logger)
|
|
61
61
|
|
|
62
62
|
@logger = logger
|
|
63
63
|
@logger.level = @log_level
|
|
@@ -69,7 +69,7 @@ module Html2rss
|
|
|
69
69
|
#
|
|
70
70
|
# @param level [Symbol, String, Integer] the new log level
|
|
71
71
|
# @return [Integer] the normalized log level
|
|
72
|
-
# @raise [
|
|
72
|
+
# @raise [InvalidOption] if the log level is invalid
|
|
73
73
|
def log_level=(level)
|
|
74
74
|
@log_level = normalize_log_level(level)
|
|
75
75
|
@logger.level = @log_level
|
|
@@ -80,13 +80,13 @@ module Html2rss
|
|
|
80
80
|
#
|
|
81
81
|
# @param formatter [Proc, Logger::Formatter, nil] the new logger formatter
|
|
82
82
|
# @return [Proc, Logger::Formatter, nil] the new logger formatter
|
|
83
|
-
# @raise [
|
|
83
|
+
# @raise [InvalidOption] if formatter is not a Proc, Logger::Formatter, or nil
|
|
84
84
|
def logger_formatter=(formatter)
|
|
85
85
|
@logger_formatter = case formatter
|
|
86
86
|
when nil, Proc, Logger::Formatter
|
|
87
87
|
formatter
|
|
88
88
|
else
|
|
89
|
-
raise
|
|
89
|
+
raise InvalidOption, 'formatter must be a Proc, Logger::Formatter, or nil'
|
|
90
90
|
end
|
|
91
91
|
@logger.formatter = @logger_formatter
|
|
92
92
|
end
|
|
@@ -96,14 +96,14 @@ module Html2rss
|
|
|
96
96
|
#
|
|
97
97
|
# @param headers [Hash, Proc, nil] the HTTP request headers to globally apply
|
|
98
98
|
# @return [Hash, Proc, nil] the assigned headers
|
|
99
|
-
# @raise [
|
|
99
|
+
# @raise [InvalidOption] if headers is not a Hash, Proc, or nil
|
|
100
100
|
def headers=(headers)
|
|
101
101
|
@headers = case headers
|
|
102
102
|
when nil then nil
|
|
103
103
|
when Hash then headers.dup.freeze
|
|
104
104
|
when Proc then headers
|
|
105
105
|
else
|
|
106
|
-
raise
|
|
106
|
+
raise InvalidOption, 'headers must be a Hash or Proc'
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
109
|
|
|
@@ -124,18 +124,9 @@ module Html2rss
|
|
|
124
124
|
#
|
|
125
125
|
# @param ttl [Integer, String, nil] the minimum TTL
|
|
126
126
|
# @return [Integer, nil] the normalized minimum TTL
|
|
127
|
-
# @raise [
|
|
127
|
+
# @raise [InvalidOption] if ttl is not a positive integer
|
|
128
128
|
def min_ttl=(ttl)
|
|
129
|
-
|
|
130
|
-
@min_ttl = nil
|
|
131
|
-
else
|
|
132
|
-
val = Integer(ttl)
|
|
133
|
-
raise ArgumentError unless val.positive?
|
|
134
|
-
|
|
135
|
-
@min_ttl = val
|
|
136
|
-
end
|
|
137
|
-
rescue ArgumentError, TypeError
|
|
138
|
-
raise ArgumentError, "min_ttl must be a positive integer, got #{ttl.inspect}"
|
|
129
|
+
@min_ttl = (positive_ttl(ttl) unless ttl.nil?)
|
|
139
130
|
end
|
|
140
131
|
|
|
141
132
|
##
|
|
@@ -143,10 +134,10 @@ module Html2rss
|
|
|
143
134
|
#
|
|
144
135
|
# @param stylesheets [Array<Hash>] the XML stylesheet processing instructions to include in the generated feed
|
|
145
136
|
# @return [Array<Hash>] the assigned stylesheets
|
|
146
|
-
# @raise [
|
|
137
|
+
# @raise [InvalidOption] if stylesheets is not an Array of hashes
|
|
147
138
|
def stylesheets=(stylesheets)
|
|
148
|
-
raise
|
|
149
|
-
raise
|
|
139
|
+
raise InvalidOption, 'stylesheets must be an Array' unless stylesheets.is_a?(Array)
|
|
140
|
+
raise InvalidOption, 'stylesheets must be an Array of Hashes' unless stylesheets.all?(Hash)
|
|
150
141
|
|
|
151
142
|
@stylesheets = stylesheets.map { |h| h.dup.freeze }.freeze
|
|
152
143
|
end
|
|
@@ -166,13 +157,20 @@ module Html2rss
|
|
|
166
157
|
|
|
167
158
|
private
|
|
168
159
|
|
|
160
|
+
def positive_ttl(ttl)
|
|
161
|
+
val = Integer(ttl, exception: false)
|
|
162
|
+
return val if val&.positive?
|
|
163
|
+
|
|
164
|
+
raise InvalidOption, "min_ttl must be a positive integer, got #{ttl.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
169
167
|
def normalize_log_level(level)
|
|
170
168
|
if level.is_a?(Integer)
|
|
171
|
-
raise
|
|
169
|
+
raise InvalidOption, "invalid log level: #{level}" unless LOG_LEVELS.value?(level)
|
|
172
170
|
|
|
173
171
|
level
|
|
174
172
|
else
|
|
175
|
-
LOG_LEVELS.fetch(level.to_s.downcase.to_sym) { raise
|
|
173
|
+
LOG_LEVELS.fetch(level.to_s.downcase.to_sym) { raise InvalidOption, "invalid log level: #{level}" }
|
|
176
174
|
end
|
|
177
175
|
end
|
|
178
176
|
end
|
|
@@ -68,12 +68,14 @@ module Html2rss
|
|
|
68
68
|
# @param base_url [String]
|
|
69
69
|
# @return [Hash{Symbol => Object}]
|
|
70
70
|
def self.health_check(base_url)
|
|
71
|
-
|
|
72
|
-
response = health_session.get(endpoint)
|
|
71
|
+
response = health_session.get("#{Url.for_channel(base_url).to_s.chomp('/')}/health")
|
|
73
72
|
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
74
73
|
|
|
75
74
|
body = JSON.parse(response.body.to_s)
|
|
76
|
-
|
|
75
|
+
status = response.status
|
|
76
|
+
{ ok: status == 200, status:, version: body['version'] }
|
|
77
|
+
rescue Html2rss::ContractError
|
|
78
|
+
raise
|
|
77
79
|
rescue StandardError => error
|
|
78
80
|
{ ok: false, error: "#{error.class}: #{error.message}" }
|
|
79
81
|
end
|
|
@@ -94,6 +96,8 @@ module Html2rss
|
|
|
94
96
|
status: wire[:status],
|
|
95
97
|
articles_count: wire[:articles_count]
|
|
96
98
|
}
|
|
99
|
+
rescue Html2rss::ContractError
|
|
100
|
+
raise
|
|
97
101
|
rescue Html2rss::Error => error
|
|
98
102
|
{ ok: false, error: "#{error.class}: #{error.message}" }
|
|
99
103
|
end
|
data/lib/html2rss/error.rb
CHANGED
|
@@ -39,6 +39,16 @@ module Html2rss
|
|
|
39
39
|
# @return [Hash{String => Object}, nil] allowlisted upstream transport telemetry when attached
|
|
40
40
|
attr_accessor :transport_meta
|
|
41
41
|
|
|
42
|
+
##
|
|
43
|
+
# Stable failure classification, overridden per error class.
|
|
44
|
+
#
|
|
45
|
+
# @return [Symbol] one of +:timeout+, +:blocked+, +:connection+, +:invalid_url+, +:budget+, +:other+
|
|
46
|
+
def kind = :other
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# @return [Boolean] whether repeating the same request may succeed (derived from {#kind})
|
|
50
|
+
def retryable? = ErrorKind[kind].retryable?
|
|
51
|
+
|
|
42
52
|
# @return [String, nil] scrape-api timeout stage when known (overridden by RequestTimedOut)
|
|
43
53
|
def timeout_phase; end
|
|
44
54
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
ErrorKind = Data.define(:name, :retryable)
|
|
5
|
+
|
|
6
|
+
##
|
|
7
|
+
# Closed classification of {Html2rss::Error} failures, so consumers branch on a stable
|
|
8
|
+
# symbol instead of exception class names. Read it through {Html2rss::Error#kind}.
|
|
9
|
+
class ErrorKind
|
|
10
|
+
# Frozen name → instance table. Only transient transport failures are retryable.
|
|
11
|
+
ALL = {
|
|
12
|
+
timeout: true,
|
|
13
|
+
blocked: false,
|
|
14
|
+
connection: true,
|
|
15
|
+
invalid_url: false,
|
|
16
|
+
budget: false,
|
|
17
|
+
other: false
|
|
18
|
+
}.to_h { |name, retryable| [name, new(name:, retryable:)] }.freeze
|
|
19
|
+
private_class_method :new
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @param name [Symbol, String] one of +ALL.keys+
|
|
23
|
+
# @return [ErrorKind]
|
|
24
|
+
# @raise [InvalidOption] when the name is not a known kind
|
|
25
|
+
def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown error kind: #{name.inspect}" }
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# @return [Boolean] whether repeating the same request may succeed
|
|
29
|
+
def retryable? = retryable
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @return [Symbol]
|
|
33
|
+
def to_sym = name
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @return [String]
|
|
37
|
+
def to_s = name.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -5,11 +5,15 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# Presentation rules for rendering an {Html2rss::Article} into feed wire formats.
|
|
7
7
|
#
|
|
8
|
-
# Owns description enrichment ({Html::Rendering::DescriptionBuilder})
|
|
9
|
-
#
|
|
8
|
+
# Owns description enrichment ({Html::Rendering::DescriptionBuilder}), RSS enclosure
|
|
9
|
+
# choice and the media-feed rule. {Article} keeps raw extracted fields only; media-file
|
|
10
|
+
# facts live on {Article::Enclosure}.
|
|
10
11
|
module ItemPresentation
|
|
11
12
|
class << self
|
|
12
13
|
##
|
|
14
|
+
# The item body: sanitized description text after at most one media element, the RSS enclosure
|
|
15
|
+
# ({.rss_enclosure_for}) when it is audio or video, else the item image ({Html::Rendering::DescriptionBuilder}).
|
|
16
|
+
#
|
|
13
17
|
# @param article [Html2rss::Article]
|
|
14
18
|
# @return [String, nil] sanitized description HTML/text for feed item bodies
|
|
15
19
|
def description_for(article)
|
|
@@ -17,24 +21,33 @@ module Html2rss
|
|
|
17
21
|
base: article.description,
|
|
18
22
|
title: article.title,
|
|
19
23
|
url: article.url,
|
|
20
|
-
|
|
24
|
+
enclosure: rss_enclosure_for(article),
|
|
21
25
|
image: article.image
|
|
22
26
|
).call
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
##
|
|
26
|
-
#
|
|
30
|
+
# The one RSS +enclosure+ of an item: its first audio or video file, else its first downloadable
|
|
31
|
+
# document or archive ({Article::Enclosure#document?}: PDF, ZIP). Images stay on the description /
|
|
32
|
+
# JSON Feed; stream playlists, Flash players and watch or player pages ({Article::Enclosure#player_page?})
|
|
33
|
+
# are never one.
|
|
27
34
|
#
|
|
28
35
|
# @param article [Html2rss::Article]
|
|
29
36
|
# @return [Html2rss::Article::Enclosure, nil]
|
|
30
37
|
def rss_enclosure_for(article)
|
|
31
|
-
|
|
38
|
+
enclosures = article.enclosures
|
|
39
|
+
enclosures.find(&:media?) || enclosures.find(&:document?)
|
|
32
40
|
end
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
##
|
|
43
|
+
# A media feed (podcast) is one where more than half of the items carry an audio or video
|
|
44
|
+
# RSS enclosure; only such a feed gets podcast (+itunes:+) tags.
|
|
45
|
+
#
|
|
46
|
+
# @param articles [Array<Html2rss::Article>]
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def media_feed?(articles)
|
|
49
|
+
media_items = articles.count { |article| article.enclosures.any?(&:media?) }
|
|
50
|
+
media_items * 2 > articles.size
|
|
38
51
|
end
|
|
39
52
|
end
|
|
40
53
|
end
|