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.
Files changed (177) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -21
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +126 -50
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -39
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
  19. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
  20. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
  21. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
  22. data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
  23. data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
  25. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
  27. data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
  28. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  29. data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
  30. data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
  31. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
  32. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
  33. data/lib/html2rss/auto_source/scraper.rb +10 -2
  34. data/lib/html2rss/auto_source/segment.rb +19 -5
  35. data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
  36. data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
  37. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  38. data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
  39. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  40. data/lib/html2rss/auto_source.rb +81 -26
  41. data/lib/html2rss/batch.rb +2 -0
  42. data/lib/html2rss/capture/README.md +5 -2
  43. data/lib/html2rss/capture.rb +44 -32
  44. data/lib/html2rss/channel.rb +30 -12
  45. data/lib/html2rss/cli.rb +22 -11
  46. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  47. data/lib/html2rss/config/issue_mapper.rb +4 -1
  48. data/lib/html2rss/config/request_controls.rb +6 -3
  49. data/lib/html2rss/config/schema.rb +15 -5
  50. data/lib/html2rss/config/selectors_validator.rb +2 -2
  51. data/lib/html2rss/config/validation_issue.rb +4 -1
  52. data/lib/html2rss/config/validator.rb +11 -0
  53. data/lib/html2rss/config.rb +53 -27
  54. data/lib/html2rss/contract_error.rb +9 -0
  55. data/lib/html2rss/defaults.rb +21 -23
  56. data/lib/html2rss/doctor/botasaurus.rb +7 -3
  57. data/lib/html2rss/error.rb +10 -0
  58. data/lib/html2rss/error_kind.rb +39 -0
  59. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  60. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  61. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  62. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  63. data/lib/html2rss/feed_builder/rss.rb +49 -6
  64. data/lib/html2rss/feed_builder.rb +2 -1
  65. data/lib/html2rss/feed_pipeline/README.md +1 -1
  66. data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
  67. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  68. data/lib/html2rss/feed_pipeline.rb +40 -27
  69. data/lib/html2rss/feed_resolution/README.md +1 -1
  70. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  71. data/lib/html2rss/feed_resolution/policy.rb +1 -1
  72. data/lib/html2rss/feed_resolution/probe.rb +3 -1
  73. data/lib/html2rss/feed_resolution.rb +11 -12
  74. data/lib/html2rss/feed_result.rb +9 -5
  75. data/lib/html2rss/hash_util.rb +3 -3
  76. data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
  77. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
  78. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  79. data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
  80. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  81. data/lib/html2rss/html/article_extractor.rb +19 -47
  82. data/lib/html2rss/html/article_rules/category.rb +3 -3
  83. data/lib/html2rss/html/article_rules/description.rb +6 -15
  84. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  85. data/lib/html2rss/html/article_rules/image.rb +30 -16
  86. data/lib/html2rss/html/feed_link.rb +7 -9
  87. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  88. data/lib/html2rss/html/navigator.rb +39 -9
  89. data/lib/html2rss/html/probe.rb +4 -0
  90. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  91. data/lib/html2rss/html/rendering.rb +2 -9
  92. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  93. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  94. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  95. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  96. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  97. data/lib/html2rss/html/title_pieces.rb +169 -0
  98. data/lib/html2rss/html/visibility.rb +56 -0
  99. data/lib/html2rss/html.rb +2 -0
  100. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  101. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  102. data/lib/html2rss/link_destination/path_classifier.rb +100 -9
  103. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  104. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  105. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  106. data/lib/html2rss/mcp/README.md +13 -1
  107. data/lib/html2rss/mcp/config_argument.rb +5 -2
  108. data/lib/html2rss/mcp/contract.rb +13 -4
  109. data/lib/html2rss/mcp/outcome.rb +14 -12
  110. data/lib/html2rss/mcp/runtime.rb +39 -2
  111. data/lib/html2rss/mcp/server.rb +13 -4
  112. data/lib/html2rss/page_recon/README.md +1 -1
  113. data/lib/html2rss/page_recon/diagnostics.rb +2 -0
  114. data/lib/html2rss/recon.rb +10 -12
  115. data/lib/html2rss/request_service/blocked_surface.rb +2 -1
  116. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  117. data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
  118. data/lib/html2rss/request_service/context.rb +15 -14
  119. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  120. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  121. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  122. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  123. data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
  124. data/lib/html2rss/request_service/policy.rb +48 -11
  125. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  126. data/lib/html2rss/request_service/response.rb +30 -13
  127. data/lib/html2rss/request_service/response_cache.rb +109 -0
  128. data/lib/html2rss/request_service/response_caching.rb +95 -0
  129. data/lib/html2rss/request_service/strategy.rb +5 -11
  130. data/lib/html2rss/request_service.rb +71 -12
  131. data/lib/html2rss/request_session/pager/base.rb +0 -7
  132. data/lib/html2rss/request_session/pager.rb +1 -1
  133. data/lib/html2rss/request_session.rb +6 -2
  134. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  135. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  136. data/lib/html2rss/scoring/engine.rb +6 -6
  137. data/lib/html2rss/scoring/observation.rb +2 -2
  138. data/lib/html2rss/selector_candidates.rb +19 -8
  139. data/lib/html2rss/selectors/README.md +1 -1
  140. data/lib/html2rss/selectors/option_spec.rb +4 -1
  141. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  142. data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
  143. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  144. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  145. data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
  146. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  147. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  148. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  149. data/lib/html2rss/selectors.rb +53 -12
  150. data/lib/html2rss/sst/attrs.rb +2 -2
  151. data/lib/html2rss/sst/document.rb +3 -3
  152. data/lib/html2rss/sst/index.rb +30 -11
  153. data/lib/html2rss/sst/node.rb +33 -26
  154. data/lib/html2rss/sst/normalizer.rb +31 -19
  155. data/lib/html2rss/sst/tags.rb +113 -2
  156. data/lib/html2rss/sst/text.rb +69 -29
  157. data/lib/html2rss/sst.rb +2 -0
  158. data/lib/html2rss/status.rb +6 -3
  159. data/lib/html2rss/surface_category.rb +1 -1
  160. data/lib/html2rss/syndication/README.md +12 -6
  161. data/lib/html2rss/syndication/alternate_links.rb +1 -1
  162. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  163. data/lib/html2rss/syndication/discovery.rb +82 -28
  164. data/lib/html2rss/syndication/feed.rb +20 -0
  165. data/lib/html2rss/syndication/item_media.rb +103 -0
  166. data/lib/html2rss/syndication/parser.rb +75 -51
  167. data/lib/html2rss/test/enhance_audit.rb +2 -2
  168. data/lib/html2rss/test/item_sample.rb +43 -0
  169. data/lib/html2rss/test.rb +42 -14
  170. data/lib/html2rss/url.rb +115 -14
  171. data/lib/html2rss/version.rb +1 -1
  172. data/lib/html2rss/word_count.rb +53 -0
  173. data/lib/html2rss.rb +17 -8
  174. data/schema/html2rss-config.schema.json +56 -41
  175. metadata +31 -4
  176. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  177. 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 # rubocop:disable Metrics/BlockLength
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 do # rubocop:disable Metrics/BlockLength
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 ArgumentError, "unmapped Dry validation predicate: #{predicate.inspect}"
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
- new(strategy:, max_redirects:, max_requests:, explicit_keys: keys)
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 ArgumentError, 'request config must be a hash'
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: 'List-card enrichment: run Html::ArticleExtractor on each matched item node ' \
131
- 'to fill missing fields from the card HTML.'
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: Html2rss::Config::AutoSourceContract.json_schema(loose: true).merge(
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. ArgumentError is the bound; Gsub maps
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 ArgumentError => error
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 ArgumentError, "unknown ValidationIssue code: #{code.inspect}" unless CODES.include?(code)
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?
@@ -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 hash fails runtime validation.
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 Psych::Exception, ArgumentError => error
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 [ArgumentError] if +string+ is not a String or does not deserialize to a Hash
69
+ # @raise [InvalidConfig] if +string+ is not a String or does not deserialize to a Hash
70
70
  def from_yaml(string)
71
- raise ArgumentError, 'YAML must be a String' unless string.is_a?(String)
71
+ raise InvalidConfig, 'YAML must be a String' unless string.is_a?(String)
72
72
 
73
73
  parsed = YAML.safe_load(string)
74
- raise ArgumentError, 'YAML must deserialize to a Hash' unless parsed.is_a?(Hash)
74
+ raise InvalidConfig, 'YAML must deserialize to a Hash' unless parsed.is_a?(Hash)
75
75
 
76
- HashUtil.deep_symbolize_keys(parsed, context: 'config')
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 [ArgumentError] if the file doesn't exist or feed is not found.
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 ArgumentError, "File '#{file}' does not exist" unless File.exist?(file)
92
- raise ArgumentError, "`#{multiple_feeds_key}` is a reserved feed name" if feed_name == multiple_feeds_key
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 = YAML.safe_load_file(file, symbolize_names: true)
95
+ yaml = read_yaml_mapping(file)
95
96
 
96
97
  return yaml unless yaml.key?(multiple_feeds_key)
97
98
 
98
- unless feed_name
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 -- keep_different_domain override stays co-located
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
- unless keep_different_domain.nil?
136
- auto_source = auto_source.merge(
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
@@ -55,9 +55,9 @@ module Html2rss
55
55
  #
56
56
  # @param logger [Logger]
57
57
  # @return [Logger] the logger
58
- # @raise [ArgumentError] if logger is not a Logger
58
+ # @raise [InvalidOption] if logger is not a Logger
59
59
  def logger=(logger)
60
- raise ArgumentError, 'logger must be a Logger' unless logger.is_a?(Logger)
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 [ArgumentError] if the log level is invalid
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 [ArgumentError] if formatter is not a Proc, Logger::Formatter, or nil
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 ArgumentError, 'formatter must be a Proc, Logger::Formatter, or nil'
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 [ArgumentError] if headers is not a Hash, Proc, or nil
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 ArgumentError, 'headers must be a Hash or Proc'
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 [ArgumentError] if ttl is not a positive integer
127
+ # @raise [InvalidOption] if ttl is not a positive integer
128
128
  def min_ttl=(ttl)
129
- if ttl.nil?
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 [ArgumentError] if stylesheets is not an Array of hashes
137
+ # @raise [InvalidOption] if stylesheets is not an Array of hashes
147
138
  def stylesheets=(stylesheets)
148
- raise ArgumentError, 'stylesheets must be an Array' unless stylesheets.is_a?(Array)
149
- raise ArgumentError, 'stylesheets must be an Array of Hashes' unless stylesheets.all?(Hash)
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 ArgumentError, "invalid log level: #{level}" unless LOG_LEVELS.value?(level)
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 ArgumentError, "invalid log level: #{level}" }
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
- endpoint = "#{Url.for_channel(base_url).to_s.chomp('/')}/health"
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
- { ok: response.status == 200, status: response.status, version: body['version'] }
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
@@ -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}) and RSS
9
- # non-image enclosure selection. {Article} keeps raw extracted fields only.
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
- enclosures: article.enclosures,
24
+ enclosure: rss_enclosure_for(article),
21
25
  image: article.image
22
26
  ).call
23
27
  end
24
28
 
25
29
  ##
26
- # First non-image enclosure for RSS +enclosure+ (images stay on description / JSON Feed).
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
- article.enclosures.find { |enc| !image_mime_type?(enc.type) }
38
+ enclosures = article.enclosures
39
+ enclosures.find(&:media?) || enclosures.find(&:document?)
32
40
  end
33
41
 
34
- private
35
-
36
- def image_mime_type?(type)
37
- type.to_s.downcase.start_with?('image/')
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