html2rss 0.22.2 → 0.24.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -108
  3. data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
  4. data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
  5. data/lib/html2rss/article.rb +213 -0
  6. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
  7. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
  8. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
  9. data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
  10. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
  11. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
  12. data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
  13. data/lib/html2rss/auto_source/discovery.rb +14 -0
  14. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
  15. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
  16. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
  17. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
  18. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
  19. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
  20. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
  21. data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
  22. data/lib/html2rss/auto_source/scraper/html.rb +22 -33
  23. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  24. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
  25. data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
  29. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  30. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  32. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  33. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  35. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  36. data/lib/html2rss/auto_source/scraper.rb +10 -3
  37. data/lib/html2rss/auto_source.rb +30 -35
  38. data/lib/html2rss/channel.rb +198 -0
  39. data/lib/html2rss/cli.rb +5 -6
  40. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  41. data/lib/html2rss/config/request_controls.rb +136 -0
  42. data/lib/html2rss/config/request_headers.rb +1 -1
  43. data/lib/html2rss/config/schema.rb +65 -114
  44. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  45. data/lib/html2rss/config/validator.rb +6 -6
  46. data/lib/html2rss/config.rb +176 -19
  47. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  48. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  49. data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
  50. data/lib/html2rss/feed_builder/json_feed.rb +81 -0
  51. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  52. data/lib/html2rss/feed_builder/rss.rb +116 -0
  53. data/lib/html2rss/feed_builder.rb +15 -0
  54. data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
  55. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  56. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  57. data/lib/html2rss/feed_pipeline.rb +83 -89
  58. data/lib/html2rss/feed_result.rb +92 -0
  59. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  60. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  61. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  62. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  63. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  64. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  65. data/lib/html2rss/html/article_extractor.rb +134 -0
  66. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  67. data/lib/html2rss/html/navigator.rb +146 -0
  68. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  70. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  71. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  72. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  73. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  74. data/lib/html2rss/html/rendering.rb +25 -0
  75. data/lib/html2rss/html.rb +8 -0
  76. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  77. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
  78. data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
  79. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  80. data/lib/html2rss/request_service/budget.rb +97 -18
  81. data/lib/html2rss/request_service/context.rb +1 -3
  82. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  83. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  84. data/lib/html2rss/request_service/network_guard.rb +125 -0
  85. data/lib/html2rss/request_service/policy.rb +10 -82
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  88. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  89. data/lib/html2rss/request_service/response.rb +9 -1
  90. data/lib/html2rss/request_service/response_guard.rb +1 -1
  91. data/lib/html2rss/request_service/strategy.rb +123 -5
  92. data/lib/html2rss/request_service.rb +7 -2
  93. data/lib/html2rss/request_session/pager/base.rb +83 -0
  94. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  95. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  96. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  97. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  98. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  99. data/lib/html2rss/request_session/pager.rb +75 -0
  100. data/lib/html2rss/request_session.rb +29 -16
  101. data/lib/html2rss/selectors/item_scope.rb +30 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  104. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  105. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  106. data/lib/html2rss/selectors.rb +84 -64
  107. data/lib/html2rss/status.rb +132 -0
  108. data/lib/html2rss/version.rb +1 -3
  109. data/lib/html2rss.rb +34 -22
  110. data/schema/html2rss-config.schema.json +109 -9
  111. metadata +71 -43
  112. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  113. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  114. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
  115. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  116. data/lib/html2rss/blocked_surface.rb +0 -65
  117. data/lib/html2rss/category_extractor.rb +0 -116
  118. data/lib/html2rss/config/class_methods.rb +0 -183
  119. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  120. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  121. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  122. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  123. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  124. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  125. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  126. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  127. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  128. data/lib/html2rss/html_extractor.rb +0 -190
  129. data/lib/html2rss/html_navigator.rb +0 -71
  130. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  131. data/lib/html2rss/json_feed_builder.rb +0 -59
  132. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  133. data/lib/html2rss/rendering/description_builder.rb +0 -87
  134. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  135. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  136. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  137. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  138. data/lib/html2rss/rendering.rb +0 -23
  139. data/lib/html2rss/request_controls.rb +0 -133
  140. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  141. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  142. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  143. data/lib/html2rss/rss_builder/article.rb +0 -208
  144. data/lib/html2rss/rss_builder/channel.rb +0 -118
  145. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  146. data/lib/html2rss/rss_builder.rb +0 -101
@@ -22,9 +22,12 @@ module Html2rss
22
22
  # Ordered scraper classes considered during auto-source extraction.
23
23
  SCRAPERS = [
24
24
  WordpressApi,
25
+ Sitemap,
25
26
  Schema,
26
27
  Microdata,
28
+ Microformats2,
27
29
  JsonState,
30
+ MetaOembed,
28
31
  SemanticHtml,
29
32
  Html
30
33
  ].freeze
@@ -67,12 +70,14 @@ module Html2rss
67
70
 
68
71
  ##
69
72
  # Returns an array of scraper classes that claim to find articles in the parsed body.
70
- # @param parsed_body [Nokogiri::HTML::Document] The parsed HTML body.
73
+ # @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
71
74
  # @param opts [Hash] The options hash.
72
75
  # @option opts [Hash] :wordpress_api scraper toggle and configuration
73
76
  # @option opts [Hash] :schema scraper toggle and configuration
74
77
  # @option opts [Hash] :microdata scraper toggle and configuration
78
+ # @option opts [Hash] :microformats2 scraper toggle and configuration
75
79
  # @option opts [Hash] :json_state scraper toggle and configuration
80
+ # @option opts [Hash] :meta_oembed scraper toggle and configuration
76
81
  # @option opts [Hash] :semantic_html scraper toggle and configuration
77
82
  # @option opts [Hash] :html scraper toggle and configuration
78
83
  # @return [Array<Class>] An array of scraper classes that can handle the parsed body.
@@ -86,14 +91,16 @@ module Html2rss
86
91
  end
87
92
 
88
93
  # Returns scraper instances ready for extraction.
89
- # @param parsed_body [Nokogiri::HTML::Document] The parsed HTML body.
94
+ # @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
90
95
  # @param url [String, Html2rss::Url] The page url.
91
96
  # @param request_session [Html2rss::RequestSession, nil] Shared follow-up session.
92
97
  # @param opts [Hash] The options hash.
93
98
  # @option opts [Hash] :wordpress_api scraper toggle and configuration
94
99
  # @option opts [Hash] :schema scraper toggle and configuration
95
100
  # @option opts [Hash] :microdata scraper toggle and configuration
101
+ # @option opts [Hash] :microformats2 scraper toggle and configuration
96
102
  # @option opts [Hash] :json_state scraper toggle and configuration
103
+ # @option opts [Hash] :meta_oembed scraper toggle and configuration
97
104
  # @option opts [Hash] :semantic_html scraper toggle and configuration
98
105
  # @option opts [Hash] :html scraper toggle and configuration
99
106
  # @return [Array<Object>] An array of scraper instances that can handle the parsed body.
@@ -139,7 +146,7 @@ module Html2rss
139
146
  private_class_method :classify_no_scraper_surface
140
147
 
141
148
  def self.blocked_surface?(parsed_body)
142
- Html2rss::BlockedSurface.interstitial?(parsed_body.to_html)
149
+ Html2rss::RequestService::BlockedSurface.interstitial?(parsed_body.to_html)
143
150
  end
144
151
  private_class_method :blocked_surface?
145
152
 
@@ -22,15 +22,26 @@ module Html2rss
22
22
  wordpress_api: {
23
23
  enabled: true
24
24
  },
25
+ sitemap: {
26
+ enabled: true,
27
+ min_priority: Discovery::Sitemap::DEFAULT_MIN_PRIORITY,
28
+ max_age_days: Discovery::Sitemap::DEFAULT_MAX_AGE_DAYS
29
+ },
25
30
  schema: {
26
31
  enabled: true
27
32
  },
28
33
  microdata: {
29
34
  enabled: true
30
35
  },
36
+ microformats2: {
37
+ enabled: true
38
+ },
31
39
  json_state: {
32
40
  enabled: true
33
41
  },
42
+ meta_oembed: {
43
+ enabled: true
44
+ },
34
45
  semantic_html: {
35
46
  enabled: true,
36
47
  fallback_anchorless: true
@@ -45,39 +56,23 @@ module Html2rss
45
56
  cleanup: Cleanup::DEFAULT_CONFIG
46
57
  }.freeze
47
58
 
48
- SCRAPER_CONFIG = proc do
49
- optional(:wordpress_api).hash do
50
- optional(:enabled).filled(:bool)
51
- end
52
- optional(:schema).hash do
53
- optional(:enabled).filled(:bool)
54
- end
55
- optional(:microdata).hash do
56
- optional(:enabled).filled(:bool)
57
- end
58
- optional(:json_state).hash do
59
- optional(:enabled).filled(:bool)
60
- end
61
- optional(:semantic_html).hash do
62
- optional(:enabled).filled(:bool)
63
- optional(:fallback_anchorless).filled(:bool)
64
- end
65
- optional(:html).hash do
66
- optional(:enabled).filled(:bool)
67
- optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
68
- optional(:use_top_selectors).filled(:integer, gt?: 0)
69
- optional(:fallback_anchorless).filled(:bool)
70
- end
71
- end.freeze
72
- private_constant :SCRAPER_CONFIG
73
-
74
- # Runtime schema used to validate auto-source config values.
75
- Config = Dry::Schema.Params do
76
- optional(:scraper).hash(&SCRAPER_CONFIG)
59
+ class << self
60
+ ##
61
+ # Returns the sum of required request slots for all enabled scrapers in the config.
62
+ #
63
+ # @param config [Hash, nil] auto_source configuration hash
64
+ # @return [Integer] total request slots required by scrapers
65
+ def request_slots_for(config)
66
+ return 0 unless config
77
67
 
78
- optional(:cleanup).hash do
79
- optional(:keep_different_domain).filled(:bool)
80
- optional(:min_words_title).filled(:integer, gt?: 0)
68
+ Scraper::SCRAPERS.sum do |scraper|
69
+ if config.dig(:scraper, scraper.options_key, :enabled)
70
+ opts = config.dig(:scraper, scraper.options_key)
71
+ scraper.respond_to?(:request_slots) ? scraper.request_slots(opts) : 0
72
+ else
73
+ 0
74
+ end
75
+ end
81
76
  end
82
77
  end
83
78
 
@@ -98,7 +93,7 @@ module Html2rss
98
93
  ##
99
94
  # Extracts article candidates by selecting every scraper that can explain the
100
95
  # page shape, running those scrapers, and normalizing the resulting hashes
101
- # into `RssBuilder::Article` objects.
96
+ # into `Article` objects.
102
97
  #
103
98
  # The contributor-facing flow is:
104
99
  # 1. choose scraper instances that match the page
@@ -108,7 +103,7 @@ module Html2rss
108
103
  # Scrapers with expensive precomputation, such as `SemanticHtml`, keep that
109
104
  # state on the instance so detection and extraction can reuse the same work.
110
105
  #
111
- # @return [Array<Html2rss::RssBuilder::Article>] extracted articles
106
+ # @return [Array<Html2rss::Article>] extracted articles
112
107
  def articles
113
108
  @articles ||= extract_articles
114
109
  rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
@@ -133,7 +128,7 @@ module Html2rss
133
128
 
134
129
  def run_scraper(instance)
135
130
  instance.each.map do |article_hash|
136
- RssBuilder::Article.new(**article_hash, scraper: instance.class)
131
+ Article.new(**article_hash, scraper: instance.class)
137
132
  end
138
133
  end
139
134
 
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ # rubocop:disable Metrics/ModuleLength -- Channel value object owns extract + freeze + Marshal
6
+ module Html2rss
7
+ ##
8
+ # Materialized channel metadata for feed builders and {FeedResult}.
9
+ #
10
+ # Extract once via {Channel.from_response}; builders consume this value object and
11
+ # do not own channel extraction. Safe to Marshal (no Response / Nokogiri retained).
12
+ # Strings and image URL are normalized and frozen at construction (including Marshal load).
13
+ #
14
+ # @!attribute [r] title
15
+ # @return [String]
16
+ # @!attribute [r] url
17
+ # @return [Html2rss::Url]
18
+ # @!attribute [r] description
19
+ # @return [String]
20
+ # @!attribute [r] language
21
+ # @return [String, nil]
22
+ # @!attribute [r] ttl
23
+ # @return [Integer]
24
+ # @!attribute [r] last_build_date
25
+ # @return [Time]
26
+ # @!attribute [r] image
27
+ # @return [Html2rss::Url, nil]
28
+ # @!attribute [r] author
29
+ # @return [String, nil]
30
+ Channel = Data.define(:title, :url, :description, :language, :ttl, :last_build_date, :image, :author) do
31
+ class << self
32
+ ##
33
+ # Materializes channel attributes from an HTTP response and optional overrides.
34
+ #
35
+ # @param response [Html2rss::RequestService::Response]
36
+ # @param overrides [Hash{Symbol => Object}] optional overrides for channel attributes
37
+ # @return [Html2rss::Channel]
38
+ def from_response(response, overrides: {})
39
+ new(
40
+ title: title_for(response, overrides),
41
+ url: url_for(response),
42
+ description: description_for(response, overrides),
43
+ language: language_for(response, overrides),
44
+ ttl: ttl_for(response, overrides),
45
+ last_build_date: last_build_date_for(response),
46
+ image: image_for(response, overrides),
47
+ author: author_for(response, overrides)
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ # Fallback RSS ttl (in minutes) when no cache directives are present.
54
+ def default_ttl_in_minutes = 360
55
+
56
+ # Description template used when no explicit or discovered description exists.
57
+ def default_description_template = 'Latest items from %<url>s'
58
+
59
+ def url_for(response) = Html2rss::Url.from_absolute(response.url)
60
+
61
+ def title_for(response, overrides)
62
+ return overrides[:title] if overrides[:title]
63
+
64
+ title = parsed_title(response)
65
+ return title if title
66
+
67
+ url_for(response).channel_titleized
68
+ end
69
+
70
+ def parsed_title(response)
71
+ return unless html_response?(response)
72
+
73
+ title = parsed_body(response).at_css('head > title')&.text.to_s
74
+ return if title.empty?
75
+
76
+ title.gsub(/\s+/, ' ').strip
77
+ end
78
+
79
+ def description_for(response, overrides)
80
+ return overrides[:description] unless overrides[:description].to_s.empty?
81
+
82
+ description = meta_description(response)
83
+ return format(default_description_template, url: url_for(response)) if description.to_s.empty?
84
+
85
+ description
86
+ end
87
+
88
+ def meta_description(response)
89
+ return unless html_response?(response)
90
+
91
+ parsed_body(response).at_css('meta[name="description"]')&.[]('content')
92
+ end
93
+
94
+ def ttl_for(response, overrides)
95
+ calculated = if overrides[:ttl]
96
+ overrides[:ttl].to_i
97
+ elsif (max_age = headers(response)['cache-control']&.match(/max-age=(\d+)/)&.[](1))
98
+ max_age.to_i.fdiv(60).ceil
99
+ else
100
+ default_ttl_in_minutes
101
+ end
102
+
103
+ min_ttl = Html2rss.defaults.min_ttl
104
+ min_ttl ? [calculated, min_ttl].max : calculated
105
+ end
106
+
107
+ def language_for(response, overrides)
108
+ return overrides[:language] if overrides[:language]
109
+
110
+ if (language_code = headers(response)['content-language']&.match(/^([a-z]{2})/))
111
+ return language_code[0]
112
+ end
113
+
114
+ return unless html_response?(response)
115
+
116
+ parsed_body(response)['lang'] || parsed_body(response).at_css('[lang]')&.[]('lang')
117
+ end
118
+
119
+ def author_for(response, overrides)
120
+ return overrides[:author] if overrides[:author]
121
+ return unless html_response?(response)
122
+
123
+ parsed_body(response).at_css('meta[name="author"]')&.[]('content')
124
+ end
125
+
126
+ def last_build_date_for(response)
127
+ header = headers(response)['last-modified']
128
+ return Time.now unless header
129
+
130
+ Time.httpdate(header)
131
+ rescue ArgumentError
132
+ Time.now
133
+ end
134
+
135
+ def image_for(response, overrides)
136
+ return overrides[:image] if overrides[:image]
137
+ return unless html_response?(response)
138
+
139
+ if (image_url = parsed_body(response).at_css('meta[property="og:image"]')&.[]('content'))
140
+ Url.sanitize(image_url)
141
+ end
142
+ end
143
+
144
+ def parsed_body(response) = response.parsed_body
145
+ def headers(response) = response.headers
146
+ def html_response?(response) = response.html_response?
147
+ end
148
+
149
+ ##
150
+ # @param title [String]
151
+ # @param url [Html2rss::Url, String]
152
+ # @param description [String]
153
+ # @param language [String, nil]
154
+ # @param ttl [Integer]
155
+ # @param last_build_date [Time, String]
156
+ # @param image [Html2rss::Url, String, nil]
157
+ # @param author [String, nil]
158
+ # rubocop:disable Metrics/ParameterLists -- Data.define members are the channel contract
159
+ def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
160
+ super(
161
+ title: freeze_string(title),
162
+ url: url.is_a?(Url) ? url : Url.from_absolute(url),
163
+ description: freeze_string(description),
164
+ language: language && freeze_string(language),
165
+ ttl: Integer(ttl),
166
+ last_build_date: normalize_last_build_date(last_build_date),
167
+ image: normalize_image(image),
168
+ author: author && freeze_string(author)
169
+ )
170
+ end
171
+ # rubocop:enable Metrics/ParameterLists
172
+
173
+ private
174
+
175
+ def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author]
176
+
177
+ def marshal_load((title, url, description, language, ttl, last_build_date, image, author))
178
+ initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
179
+ end
180
+
181
+ def freeze_string(value) = value.to_s.dup.freeze
182
+
183
+ def normalize_image(value)
184
+ return value if value.is_a?(Url)
185
+ return if value.nil?
186
+
187
+ Url.sanitize(value.to_s)
188
+ end
189
+
190
+ def normalize_last_build_date(value)
191
+ return value if value.is_a?(Time)
192
+ raise ArgumentError, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
193
+
194
+ Time.httpdate(value)
195
+ end
196
+ end
197
+ # rubocop:enable Metrics/ModuleLength
198
+ end
data/lib/html2rss/cli.rb CHANGED
@@ -4,21 +4,20 @@ require 'fileutils'
4
4
  require 'json'
5
5
  require 'thor'
6
6
 
7
- ##
8
- # The Html2rss namespace / command line interface.
9
7
  module Html2rss
10
8
  ##
11
9
  # The Html2rss command line interface.
12
10
  class CLI < Thor # rubocop:disable Metrics/ClassLength
13
11
  check_unknown_options!
14
- # Ordered fallback chain attempted by auto strategy.
12
+ # Ordered fallback chain attempted by the feed-level :auto plan.
15
13
  #
16
14
  # @return [Array<Symbol>]
17
15
  AUTO_FALLBACK_CHAIN = Html2rss::FeedPipeline::AutoFallback::CHAIN.freeze
18
- # Supported CLI strategy option values.
16
+ # Supported CLI strategy plan option values (:auto plus concrete strategies).
19
17
  #
20
18
  # @return [Array<String>]
21
- STRATEGY_OPTION_ENUM = (['auto'] + Html2rss::RequestService.strategy_names).uniq.freeze
19
+ STRATEGY_OPTION_ENUM = Html2rss::FeedPipeline::StrategyPlan.accepted_names.map(&:to_s).freeze
20
+
22
21
  # User-facing strategy help text that reflects the current fallback chain.
23
22
  #
24
23
  # @return [String]
@@ -188,7 +187,7 @@ module Html2rss
188
187
  end
189
188
 
190
189
  def request_controls
191
- Html2rss::RequestControls.new(
190
+ Html2rss::Config::RequestControls.new(
192
191
  strategy: options[:strategy]&.to_sym,
193
192
  max_redirects: options[:max_redirects],
194
193
  max_requests: options[:max_requests],
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-validation'
4
+
5
+ module Html2rss
6
+ class Config
7
+ # Runtime source of truth for validating auto-source config values.
8
+ AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
9
+ optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
10
+ optional(:wordpress_api).hash do
11
+ optional(:enabled).filled(:bool)
12
+ end
13
+ optional(:sitemap).hash do
14
+ optional(:enabled).filled(:bool)
15
+ optional(:min_priority).filled(:float)
16
+ optional(:max_age_days).filled(:integer, gt?: 0)
17
+ end
18
+ optional(:schema).hash do
19
+ optional(:enabled).filled(:bool)
20
+ end
21
+ optional(:microdata).hash do
22
+ optional(:enabled).filled(:bool)
23
+ end
24
+ optional(:microformats2).hash do
25
+ optional(:enabled).filled(:bool)
26
+ end
27
+ optional(:json_state).hash do
28
+ optional(:enabled).filled(:bool)
29
+ end
30
+ optional(:meta_oembed).hash do
31
+ optional(:enabled).filled(:bool)
32
+ end
33
+ optional(:semantic_html).hash do
34
+ optional(:enabled).filled(:bool)
35
+ optional(:fallback_anchorless).filled(:bool)
36
+ end
37
+ optional(:html).hash do
38
+ optional(:enabled).filled(:bool)
39
+ optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
40
+ optional(:use_top_selectors).filled(:integer, gt?: 0)
41
+ optional(:fallback_anchorless).filled(:bool)
42
+ end
43
+ end
44
+
45
+ optional(:cleanup).hash do
46
+ optional(:keep_different_domain).filled(:bool)
47
+ optional(:min_words_title).filled(:integer, gt?: 0)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Config
5
+ ##
6
+ # Wire provenance for request control values (explicit vs default).
7
+ class RequestControls
8
+ # Request-control keys accepted at the top level of feed config.
9
+ TOP_LEVEL_KEYS = %i[strategy].freeze
10
+ # Request-control keys accepted under the nested `request` config.
11
+ REQUEST_KEYS = %i[max_redirects max_requests total_timeout_seconds].freeze
12
+
13
+ ##
14
+ # @param config [Hash{Symbol => Object}] raw config input
15
+ # @return [RequestControls] request controls extracted from the config hash
16
+ def self.from_config(config)
17
+ HashUtil.assert_symbol_keys!(config, context: 'config', deep: false)
18
+ HashUtil.assert_symbol_keys!(config[:request], context: 'config[:request]') if config[:request].is_a?(Hash)
19
+
20
+ new(
21
+ strategy: config[:strategy],
22
+ max_redirects: request_value_for(config, :max_redirects),
23
+ max_requests: request_value_for(config, :max_requests),
24
+ total_timeout_seconds: request_value_for(config, :total_timeout_seconds),
25
+ explicit_keys: explicit_keys_for(config)
26
+ )
27
+ end
28
+
29
+ def self.explicit_keys_for(config)
30
+ TOP_LEVEL_KEYS.filter { config.key?(_1) } +
31
+ REQUEST_KEYS.filter { request_key?(config, _1) }
32
+ end
33
+
34
+ def self.request_value_for(config, key)
35
+ request_config = config[:request]
36
+ return nil unless request_config.is_a?(Hash)
37
+
38
+ request_config[key]
39
+ end
40
+
41
+ def self.request_key?(config, key)
42
+ request_config = config[:request]
43
+ request_config.is_a?(Hash) && request_config.key?(key)
44
+ end
45
+
46
+ private_class_method :explicit_keys_for, :request_value_for, :request_key?
47
+
48
+ ##
49
+ # @param strategy [Symbol, nil] effective request strategy
50
+ # @param max_redirects [Integer, nil] effective redirect limit
51
+ # @param max_requests [Integer, nil] effective request budget
52
+ # @param total_timeout_seconds [Integer, nil] effective request timeout
53
+ # @param explicit_keys [Array<Symbol>] controls explicitly supplied by the caller
54
+ def initialize(strategy: nil, max_redirects: nil, max_requests: nil,
55
+ total_timeout_seconds: nil, explicit_keys: [])
56
+ @strategy = strategy
57
+ @max_redirects = max_redirects
58
+ @max_requests = max_requests
59
+ @total_timeout_seconds = total_timeout_seconds
60
+ @explicit_keys = explicit_keys.map(&:to_sym).uniq.freeze
61
+ freeze
62
+ end
63
+
64
+ ##
65
+ # @return [Symbol, nil] effective request strategy
66
+ attr_reader :strategy
67
+
68
+ ##
69
+ # @return [Integer, nil] effective redirect limit
70
+ attr_reader :max_redirects
71
+
72
+ ##
73
+ # @return [Integer, nil] effective request budget
74
+ attr_reader :max_requests
75
+
76
+ ##
77
+ # @return [Integer, nil] effective request timeout
78
+ attr_reader :total_timeout_seconds
79
+
80
+ ##
81
+ # @param name [Symbol, String] request control name
82
+ # @return [Boolean] whether the control was explicitly supplied
83
+ def explicit?(name)
84
+ explicit_keys.include?(name.to_sym)
85
+ end
86
+
87
+ ##
88
+ # @param strategy [Symbol, nil] validated request strategy
89
+ # @param max_redirects [Integer, nil] validated redirect limit
90
+ # @param max_requests [Integer, nil] validated request budget
91
+ # @param total_timeout_seconds [Integer, nil] validated request timeout
92
+ # @return [RequestControls] controls updated with validated effective values
93
+ def with_effective_values(strategy:, max_redirects:, max_requests:, total_timeout_seconds:)
94
+ self.class.new(
95
+ strategy:,
96
+ max_redirects:,
97
+ max_requests:,
98
+ total_timeout_seconds:,
99
+ explicit_keys:
100
+ )
101
+ end
102
+
103
+ ##
104
+ # Applies only explicitly set controls to the provided config hash.
105
+ #
106
+ # @param config [Hash{Symbol => Object}] mutable config hash
107
+ # @return [Hash{Symbol => Object}] the same hash with explicit controls written
108
+ def apply_to(config)
109
+ config[:strategy] = strategy if explicit?(:strategy)
110
+ apply_request_value(config, :max_redirects, max_redirects)
111
+ apply_request_value(config, :max_requests, max_requests)
112
+ apply_request_value(config, :total_timeout_seconds, total_timeout_seconds)
113
+ config
114
+ end
115
+
116
+ private
117
+
118
+ attr_reader :explicit_keys
119
+
120
+ def apply_request_value(config, key, value)
121
+ return unless explicit?(key)
122
+
123
+ ensure_request_config!(config)
124
+ config[:request][key] = value
125
+ end
126
+
127
+ def ensure_request_config!(config)
128
+ request_config = config[:request]
129
+ return config[:request] = {} if request_config.nil?
130
+ return if request_config.is_a?(Hash)
131
+
132
+ raise ArgumentError, 'request config must be a hash'
133
+ end
134
+ end
135
+ end
136
+ end
@@ -41,7 +41,7 @@ module Html2rss
41
41
  # @return [Hash{String => String}] the default header set merged with global defaults
42
42
  def browser_defaults
43
43
  defaults = DEFAULT_HEADERS.dup
44
- global_headers = Html2rss.configuration.headers
44
+ global_headers = Html2rss.defaults.headers
45
45
  global_headers = global_headers.call if global_headers.respond_to?(:call)
46
46
 
47
47
  if global_headers.is_a?(Hash)