html2rss 0.32.3 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +54 -1
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/README.md +1 -1
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
  48. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  49. data/lib/html2rss/feed_resolution/options.rb +3 -7
  50. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  51. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  52. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  53. data/lib/html2rss/feed_resolution.rb +5 -9
  54. data/lib/html2rss/feed_result.rb +4 -3
  55. data/lib/html2rss/hash_util.rb +91 -90
  56. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  59. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  60. data/lib/html2rss/html/probe.rb +51 -51
  61. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  62. data/lib/html2rss/mcp/config_argument.rb +9 -2
  63. data/lib/html2rss/mcp/contract.rb +7 -5
  64. data/lib/html2rss/mcp/outcome.rb +4 -5
  65. data/lib/html2rss/mcp/runtime.rb +17 -24
  66. data/lib/html2rss/mcp/server/tools.rb +9 -9
  67. data/lib/html2rss/option.rb +48 -0
  68. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  69. data/lib/html2rss/page_recon.rb +0 -4
  70. data/lib/html2rss/recon.rb +10 -12
  71. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  72. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  73. data/lib/html2rss/request_service/budget.rb +3 -24
  74. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  75. data/lib/html2rss/request_service/context.rb +1 -3
  76. data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
  77. data/lib/html2rss/request_service/network_guard.rb +3 -2
  78. data/lib/html2rss/request_service/policy.rb +8 -26
  79. data/lib/html2rss/request_service/response.rb +3 -6
  80. data/lib/html2rss/request_service/strategy.rb +2 -2
  81. data/lib/html2rss/request_service.rb +26 -2
  82. data/lib/html2rss/request_session/pager/base.rb +3 -3
  83. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  84. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  85. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  86. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  87. data/lib/html2rss/scoring/engine.rb +4 -32
  88. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  89. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  90. data/lib/html2rss/selectors/json_xml.rb +33 -34
  91. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  92. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  93. data/lib/html2rss/selectors/schema_export.rb +60 -60
  94. data/lib/html2rss/selectors.rb +14 -18
  95. data/lib/html2rss/sst/node.rb +9 -3
  96. data/lib/html2rss/sst/normalizer.rb +25 -24
  97. data/lib/html2rss/sst/text.rb +53 -57
  98. data/lib/html2rss/status.rb +23 -30
  99. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  100. data/lib/html2rss/syndication/discovery.rb +117 -187
  101. data/lib/html2rss/syndication/parser.rb +92 -115
  102. data/lib/html2rss/test/enhance_audit.rb +13 -15
  103. data/lib/html2rss/test/policy.rb +9 -11
  104. data/lib/html2rss/test.rb +29 -48
  105. data/lib/html2rss/url.rb +53 -53
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +2 -1
  108. metadata +4 -2
@@ -47,13 +47,13 @@ module Html2rss
47
47
  allow_private_networks: DEFAULTS[:allow_private_networks],
48
48
  allow_cross_origin_followups: DEFAULTS[:allow_cross_origin_followups],
49
49
  resolver: Socket)
50
- @connect_timeout_seconds = validate_positive_integer!(:connect_timeout_seconds, connect_timeout_seconds)
51
- @read_timeout_seconds = validate_positive_integer!(:read_timeout_seconds, read_timeout_seconds)
52
- @total_timeout_seconds = validate_positive_integer!(:total_timeout_seconds, total_timeout_seconds)
53
- @max_redirects = validate_non_negative_integer!(:max_redirects, max_redirects)
54
- @max_response_bytes = validate_positive_integer!(:max_response_bytes, max_response_bytes)
55
- @max_decompressed_bytes = validate_positive_integer!(:max_decompressed_bytes, max_decompressed_bytes)
56
- @max_requests = [validate_positive_integer!(:max_requests, max_requests), MAX_REQUESTS_CEILING].min
50
+ @connect_timeout_seconds = Option.integer(:connect_timeout_seconds, connect_timeout_seconds, min: 1)
51
+ @read_timeout_seconds = Option.integer(:read_timeout_seconds, read_timeout_seconds, min: 1)
52
+ @total_timeout_seconds = Option.integer(:total_timeout_seconds, total_timeout_seconds, min: 1)
53
+ @max_redirects = Option.integer(:max_redirects, max_redirects, min: 0)
54
+ @max_response_bytes = Option.integer(:max_response_bytes, max_response_bytes, min: 1)
55
+ @max_decompressed_bytes = Option.integer(:max_decompressed_bytes, max_decompressed_bytes, min: 1)
56
+ @max_requests = [Option.integer(:max_requests, max_requests, min: 1), MAX_REQUESTS_CEILING].min
57
57
  @allow_private_networks = allow_private_networks ? true : false
58
58
  @allow_cross_origin_followups = allow_cross_origin_followups ? true : false
59
59
  @network_guard = NetworkGuard.new(allow_private_networks: @allow_private_networks, resolver:)
@@ -135,18 +135,6 @@ module Html2rss
135
135
 
136
136
  attr_reader :network_guard
137
137
 
138
- def validate_positive_integer!(name, value)
139
- raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
140
-
141
- value
142
- end
143
-
144
- def validate_non_negative_integer!(name, value)
145
- raise ArgumentError, "#{name} must be non-negative" unless value.is_a?(Integer) && !value.negative?
146
-
147
- value
148
- end
149
-
150
138
  def enforce_same_origin!(url, origin_url, relation)
151
139
  return if relation == :initial || allow_cross_origin_followups?
152
140
 
@@ -163,13 +151,7 @@ module Html2rss
163
151
  end
164
152
 
165
153
  def comparable_origin(url)
166
- [url.host, normalized_port(url)]
167
- end
168
-
169
- def normalized_port(url)
170
- return url.port if url.port
171
-
172
- url.scheme == 'https' ? 443 : 80
154
+ [url.scheme, url.host, url.port]
173
155
  end
174
156
  end
175
157
 
@@ -31,15 +31,12 @@ module Html2rss
31
31
  # @param status [Integer, nil] the HTTP status code when available
32
32
  # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
33
33
  # @param captured_responses [Array<Hash>] JSON XHR/fetch bodies captured during browser scrapes
34
- # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- transport + capture fields stay co-located
34
+ # rubocop:disable-next Metrics/ParameterLists -- transport + capture fields stay co-located
35
35
  def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META,
36
36
  captured_responses: EMPTY_CAPTURED_RESPONSES)
37
37
  @body = body
38
38
 
39
- headers = (headers || {}).transform_keys(&:to_s)
40
- HashUtil.assert_string_keys!(headers, context: 'response headers', deep: false)
41
-
42
- @headers = headers
39
+ @headers = (headers || {}).transform_keys(&:to_s)
43
40
  @status = status
44
41
  @url = url
45
42
  @transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
@@ -165,7 +162,7 @@ module Html2rss
165
162
  end
166
163
 
167
164
  def feed_looking_body?
168
- snippet = body.to_s[0, 800].downcase
165
+ snippet = body.to_s.b[0, 800].downcase
169
166
  FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) }
170
167
  end
171
168
  end
@@ -133,9 +133,9 @@ module Html2rss
133
133
 
134
134
  # @param error [StandardError]
135
135
  # @return [void]
136
- # @raise [StandardError]
136
+ # @raise [ConnectionFailed]
137
137
  def translate_connection_error(error)
138
- raise error
138
+ raise ConnectionFailed, error.message
139
139
  end
140
140
 
141
141
  # @param error [StandardError]
@@ -28,6 +28,8 @@ module Html2rss
28
28
  class RedirectLimitReached < Html2rss::Error; end
29
29
  # Raised when blocked content surfaces are detected.
30
30
  class BlockedSurfaceDetected < Html2rss::Error; end
31
+ # Raised when the target connection fails before a response document exists.
32
+ class ConnectionFailed < Html2rss::Error; end
31
33
 
32
34
  # Raised when a request times out.
33
35
  class RequestTimedOut < Html2rss::Error
@@ -76,6 +78,12 @@ module Html2rss
76
78
  # @return [Symbol]
77
79
  DEFAULT_STRATEGY_NAME = :default
78
80
 
81
+ # Maximum distinct [strategy, host] deprecation warnings tracked per process.
82
+ MAX_DEPRECATION_WARNINGS = 256
83
+ # Mutex guarding process-local deprecation warning deduplication.
84
+ DEPRECATION_MUTEX = Mutex.new
85
+ private_constant :DEPRECATION_MUTEX
86
+
79
87
  class << self
80
88
  # @return [Symbol] the default strategy name
81
89
  def default_strategy_name = DEFAULT_STRATEGY_NAME
@@ -107,6 +115,13 @@ module Html2rss
107
115
  strategy_class.new(ctx).execute
108
116
  end
109
117
 
118
+ ##
119
+ # Clears process-local deprecation warning state (used by specs).
120
+ # @return [void]
121
+ def reset_deprecation_warnings!
122
+ DEPRECATION_MUTEX.synchronize { @warned_deprecations = Set.new }
123
+ end
124
+
110
125
  private
111
126
 
112
127
  ##
@@ -125,14 +140,15 @@ module Html2rss
125
140
  end
126
141
 
127
142
  ##
128
- # Emits a structured +strategy_deprecated+ warning once per session (+ :initial + only).
143
+ # Emits a structured +strategy_deprecated+ warning once per +(strategy, host)+ per process
144
+ # on initial session requests (+relation == :initial+). Multi-worker servers may emit once per worker.
129
145
  # @param strategy [Symbol] deprecated strategy alias
130
146
  # @param replacement [Symbol] preferred strategy name
131
147
  # @param relation [Symbol] request relation
132
148
  # @param host [String, nil] request host
133
149
  # @return [void]
134
150
  def warn_migration_strategy(strategy, replacement, relation:, host:)
135
- return unless relation == :initial
151
+ return unless relation == :initial && claim_deprecation_warning?(strategy, host)
136
152
 
137
153
  Log.warn({
138
154
  event_name: 'strategy_deprecated',
@@ -142,6 +158,14 @@ module Html2rss
142
158
  message: "#{name}: strategy=#{strategy.inspect} is deprecated; using #{replacement.inspect} instead."
143
159
  }.to_json)
144
160
  end
161
+
162
+ def claim_deprecation_warning?(strategy, host)
163
+ DEPRECATION_MUTEX.synchronize do
164
+ @warned_deprecations ||= Set.new
165
+ @warned_deprecations.clear if @warned_deprecations.size >= MAX_DEPRECATION_WARNINGS
166
+ !@warned_deprecations.add?([strategy, host]).nil?
167
+ end
168
+ end
145
169
  end
146
170
  end
147
171
  end
@@ -26,7 +26,7 @@ module Html2rss
26
26
  # @yield [RequestService::Response] each page response
27
27
  # @return [Enumerator] enumerator when no block is given
28
28
  def each
29
- return enum_for(:each) unless block_given?
29
+ return enum_for unless block_given?
30
30
 
31
31
  yield initial_response
32
32
 
@@ -48,14 +48,14 @@ module Html2rss
48
48
 
49
49
  # @return [Integer] configured maximum pages (always a positive Integer)
50
50
  def max_pages
51
- value = config.fetch(:max_pages, DEFAULT_MAX_PAGES)
51
+ value = config[:max_pages]
52
52
  value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_PAGES
53
53
  end
54
54
 
55
55
  # @param next_url [Html2rss::Url, String, nil]
56
56
  # @return [Boolean]
57
57
  def follow_up_allowed?(next_url)
58
- !next_url.nil? && !next_url.to_s.empty? && !session.visited?(next_url)
58
+ !next_url.to_s.empty? && !session.visited?(next_url)
59
59
  end
60
60
 
61
61
  # @param next_url [Html2rss::Url, String]
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # @param page_response [RequestService::Response]
12
12
  # @param page_number [Integer]
13
13
  # @return [String, nil]
14
- def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
15
15
  selector = config[:selector]
16
16
  return nil if selector.nil? || selector.empty?
17
17
 
@@ -19,16 +19,16 @@ module Html2rss
19
19
  return nil unless node
20
20
 
21
21
  href = node['href'] || node.text
22
- return nil if href.nil? || href.strip.empty?
22
+ return nil if href.strip.empty?
23
23
 
24
- Html2rss::Url.from_relative(href.strip, page_response.url)
24
+ Html2rss::Url.from_relative(href, page_response.url)
25
25
  end
26
26
 
27
27
  # @param parsed_body [Nokogiri::HTML::Document]
28
28
  # @param selector [String]
29
29
  # @return [Nokogiri::XML::Node, nil]
30
30
  def extract_node(parsed_body, selector)
31
- parsed_body.at_css(selector) || parsed_body.at_xpath(selector)
31
+ parsed_body.at_css(selector)
32
32
  rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
33
33
  # Pure XPath like `descendant::a` fails LOOKS_LIKE_XPATH / at_css; fall back explicitly.
34
34
  parsed_body.at_xpath(selector)
@@ -25,14 +25,8 @@ module Html2rss
25
25
  # @return [Html2rss::Url]
26
26
  def next_page_url(page_response, page_number:)
27
27
  param = config.fetch(:param, DEFAULT_PARAM)
28
- target_offset_val = target_offset_for(page_number)
29
28
  url = Html2rss::Url.from_absolute(page_response.url)
30
-
31
- if url.to_s.include?('{offset}')
32
- Html2rss::Url.from_absolute(url.to_s.gsub('{offset}', target_offset_val.to_s))
33
- else
34
- url.with_query_values(url.query_values.merge(param.to_s => target_offset_val.to_s))
35
- end
29
+ url.with_query_values(url.query_values.merge(param.to_s => target_offset_for(page_number).to_s))
36
30
  end
37
31
 
38
32
  # @param page_number [Integer]
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # @param page_response [RequestService::Response]
12
12
  # @param page_number [Integer]
13
13
  # @return [String, nil]
14
- def next_page_url(page_response, page_number: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
15
15
  href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
16
16
  return nil if href.nil? || href.empty?
17
17
 
@@ -25,14 +25,8 @@ module Html2rss
25
25
  # @return [Html2rss::Url]
26
26
  def next_page_url(page_response, page_number:)
27
27
  param = config.fetch(:param, DEFAULT_PARAM)
28
- target_page_val = target_page_for(page_number)
29
28
  url = Html2rss::Url.from_absolute(page_response.url)
30
-
31
- if url.to_s.include?('{page}')
32
- Html2rss::Url.from_absolute(url.to_s.gsub('{page}', target_page_val.to_s))
33
- else
34
- url.with_query_values(url.query_values.merge(param.to_s => target_page_val.to_s))
35
- end
29
+ url.with_query_values(url.query_values.merge(param.to_s => target_page_for(page_number).to_s))
36
30
  end
37
31
 
38
32
  # @param page_number [Integer]
@@ -91,21 +91,17 @@ module Html2rss
91
91
 
92
92
  private
93
93
 
94
- def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/MethodLength
94
+ def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/AbcSize
95
95
  facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
96
96
  obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
97
97
  return if obs.hard_junk?(mode: hard_junk_mode)
98
98
 
99
99
  quality, quality_parts = apply_rules(QUALITY_RULES, obs)
100
100
  junk, junk_parts = apply_rules(JUNK_RULES, obs)
101
- ranked(
101
+ RankedSegment.new(
102
102
  segment:,
103
- score: build_score(
104
- composite: quality - junk,
105
- quality:,
106
- junk:,
107
- breakdown: quality_parts.merge(junk_parts)
108
- )
103
+ score: Score.new(composite: (quality - junk).to_f, quality: quality.to_f, junk: junk.to_f,
104
+ breakdown: quality_parts.merge(junk_parts).freeze)
109
105
  )
110
106
  end
111
107
 
@@ -116,30 +112,6 @@ module Html2rss
116
112
  end
117
113
  [parts.values.sum, parts]
118
114
  end
119
-
120
- def build_score(composite:, quality: nil, junk: nil, breakdown: nil)
121
- raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
122
-
123
- Score.new(
124
- composite: composite.to_f,
125
- quality: (quality.nil? ? composite : quality).to_f,
126
- junk: (junk || 0).to_f,
127
- breakdown: normalize_breakdown(breakdown)
128
- )
129
- end
130
-
131
- def normalize_breakdown(breakdown)
132
- return Score::EMPTY_BREAKDOWN if breakdown.nil?
133
-
134
- breakdown.transform_keys { |id| self.class.assert_feature_id!(id) }.freeze
135
- end
136
-
137
- def ranked(segment:, score:)
138
- raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
139
- raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
140
-
141
- RankedSegment.new(segment:, score:)
142
- end
143
115
  end
144
116
  end
145
117
  end
@@ -24,7 +24,6 @@ module Html2rss
24
24
  def initialize(base_url)
25
25
  @base_url = base_url
26
26
  @by_href = {}
27
- @by_node = {}.compare_by_identity
28
27
  @text_classifier = LinkDestination::TextClassifier.new
29
28
  end
30
29
 
@@ -35,15 +34,11 @@ module Html2rss
35
34
  # @param node_or_href [SST::Node, String, #to_s]
36
35
  # @return [LinkDestination::DestinationFacts, nil]
37
36
  def destination_facts(node_or_href)
38
- return @by_node[node_or_href] if node_or_href.is_a?(SST::Node) && @by_node.key?(node_or_href)
39
-
40
37
  href = extract_href(node_or_href)
41
38
  return unless href
42
39
 
43
- facts = (@by_href[href] ||= build_facts(href))
44
- @by_node[node_or_href] = facts if node_or_href.is_a?(SST::Node)
45
- facts
46
- rescue ArgumentError
40
+ @by_href[href] ||= build_facts(href)
41
+ rescue Html2rss::Url::InvalidUrlError
47
42
  nil
48
43
  end
49
44
 
@@ -54,11 +54,9 @@ module Html2rss
54
54
  ##
55
55
  # Retrieves and returns the normalized absolute URL.
56
56
  #
57
- # @return [Html2rss::Url, nil] The absolute URL.
57
+ # @return [Html2rss::Url, nil] The absolute URL, or nil when nothing matched or the +href+ is blank.
58
58
  def call
59
- return nil unless @href
60
-
61
- Url.from_relative(@href, @base_url)
59
+ Url.from_relative(@href, @base_url) unless @href.strip.empty?
62
60
  end
63
61
  end
64
62
  end
@@ -14,48 +14,47 @@ module Html2rss
14
14
  array: ['<array>', '</array>']
15
15
  }.freeze
16
16
 
17
- module_function
18
-
19
- ##
20
- # Converts the object to XML format.
21
- #
22
- # @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
23
- # @return [String] representing the object in XML
24
- def call(object)
25
- object_to_xml(object).tap do |converted|
26
- Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
17
+ class << self
18
+ ##
19
+ # Converts the object to XML format.
20
+ #
21
+ # @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
22
+ # @return [String] representing the object in XML
23
+ def call(object)
24
+ object_to_xml(object).tap do |converted|
25
+ Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
26
+ end
27
27
  end
28
- end
29
28
 
30
- def object_to_xml(object)
31
- case object
32
- when Hash
33
- hash_to_xml(object)
34
- when Array
35
- array_to_xml(object)
36
- else
37
- CGI.escapeHTML(object.to_s)
29
+ private
30
+
31
+ def object_to_xml(object)
32
+ case object
33
+ when Hash
34
+ hash_to_xml(object)
35
+ when Array
36
+ array_to_xml(object)
37
+ else
38
+ CGI.escapeHTML(object.to_s)
39
+ end
38
40
  end
39
- end
40
- private_class_method :object_to_xml
41
41
 
42
- def hash_to_xml(object)
43
- prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
44
- inner_xml = object.each_with_object(+'') do |(key, value), str|
45
- str << "<#{key}>#{object_to_xml(value)}</#{key}>"
46
- end
42
+ def hash_to_xml(object)
43
+ prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
44
+ inner_xml = object.each_with_object(+'') do |(key, value), str|
45
+ str << "<#{key}>#{object_to_xml(value)}</#{key}>"
46
+ end
47
47
 
48
- "#{prefix}#{inner_xml}#{suffix}"
49
- end
50
- private_class_method :hash_to_xml
48
+ "#{prefix}#{inner_xml}#{suffix}"
49
+ end
51
50
 
52
- def array_to_xml(object)
53
- prefix, suffix = OBJECT_TO_XML_TAGS[:array]
54
- inner_xml = object.each_with_object(+'') { |value, str| str << object_to_xml(value) }
51
+ def array_to_xml(object)
52
+ prefix, suffix = OBJECT_TO_XML_TAGS[:array]
53
+ inner_xml = object.each_with_object(+'') { |value, str| str << object_to_xml(value) }
55
54
 
56
- "#{prefix}#{inner_xml}#{suffix}"
55
+ "#{prefix}#{inner_xml}#{suffix}"
56
+ end
57
57
  end
58
- private_class_method :array_to_xml
59
58
  end
60
59
  end
61
60
  end
@@ -101,7 +101,7 @@ module Html2rss
101
101
 
102
102
  source = regexp_source(string)
103
103
 
104
- expression = Regexp::Parser.parse(source, options: ::Regexp::EXTENDED | ::Regexp::IGNORECASE)
104
+ expression = Regexp::Parser.parse(source)
105
105
  raise ArgumentError, 'pattern contains nested quantifiers' if nested_quantifiers?(expression)
106
106
 
107
107
  expression.to_re
@@ -161,8 +161,7 @@ module Html2rss
161
161
  ##
162
162
  # @return [Regexp]
163
163
  def pattern
164
- raw = @pattern
165
- raw.is_a?(String) ? self.class.compiled_pattern(raw) : raw
164
+ self.class.compiled_pattern(@pattern)
166
165
  end
167
166
  end
168
167
  end
@@ -67,11 +67,10 @@ module Html2rss
67
67
  def self.validate_args!(_value, context)
68
68
  time_zone_value = context.time_zone
69
69
 
70
- if time_zone_value.nil? || time_zone_value.empty?
71
- raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil
72
- end
70
+ raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.nil?
73
71
 
74
72
  assert_type(time_zone_value, String, :time_zone, context:)
73
+ raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.empty?
75
74
  end
76
75
 
77
76
  ##
@@ -89,7 +88,7 @@ module Html2rss
89
88
  private
90
89
 
91
90
  def parse_time(string, time_zone)
92
- components = Date._parse(string, false)
91
+ components = Date._parse(string)
93
92
  if components[:zone] || components.key?(:offset)
94
93
  Time.parse(string)
95
94
  else
@@ -104,7 +103,7 @@ module Html2rss
104
103
  components[:mday],
105
104
  components[:hour] || 0,
106
105
  components[:min] || 0,
107
- (components[:sec] || 0) + (components[:sec_fraction] || 0)
106
+ components[:sec] || 0
108
107
  )
109
108
  end
110
109
  end
@@ -8,72 +8,72 @@ module Html2rss
8
8
  # Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTIONS+;
9
9
  # this module is the export adapter only.
10
10
  module SchemaExport
11
- module_function
12
-
13
- ##
14
- # @param name [Symbol, String] registry key
15
- # @param klass [Class] post-processor class
16
- # @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
17
- def for_post_processor(name:, klass:)
18
- name = name.to_s
19
- {
20
- type: 'object',
21
- title: name,
22
- description: klass::DESCRIPTION,
23
- examples: klass::EXAMPLES,
24
- properties: post_processor_properties(name, klass),
25
- required: post_processor_required(klass),
26
- additionalProperties: true
27
- }
28
- end
11
+ class << self
12
+ ##
13
+ # @param name [Symbol, String] registry key
14
+ # @param klass [Class] post-processor class
15
+ # @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
16
+ def for_post_processor(name:, klass:)
17
+ name = name.to_s
18
+ {
19
+ type: 'object',
20
+ title: name,
21
+ description: klass::DESCRIPTION,
22
+ examples: klass::EXAMPLES,
23
+ properties: post_processor_properties(name, klass),
24
+ required: post_processor_required(klass),
25
+ additionalProperties: true
26
+ }
27
+ end
29
28
 
30
- ##
31
- # @param name [Symbol, String] registry key
32
- # @param klass [Class] extractor class
33
- # @return [Hash{Symbol => Object}] JSON Schema for one extractor name
34
- def for_extractor(name:, klass:)
35
- name = name.to_s
36
- {
37
- type: 'string',
38
- const: name,
39
- title: name,
40
- description: klass::DESCRIPTION,
41
- examples: klass::EXAMPLES
42
- }
43
- end
29
+ ##
30
+ # @param name [Symbol, String] registry key
31
+ # @param klass [Class] extractor class
32
+ # @return [Hash{Symbol => Object}] JSON Schema for one extractor name
33
+ def for_extractor(name:, klass:)
34
+ name = name.to_s
35
+ {
36
+ type: 'string',
37
+ const: name,
38
+ title: name,
39
+ description: klass::DESCRIPTION,
40
+ examples: klass::EXAMPLES
41
+ }
42
+ end
44
43
 
45
- ##
46
- # @param name [String] registry key
47
- # @param klass [Class]
48
- # @return [Hash{Symbol => Hash}]
49
- def post_processor_properties(name, klass)
50
- properties = { name: { type: 'string', const: name } }
51
- OptionSpec.for(klass).each do |spec|
52
- properties[spec.name] = property_schema_for(spec.type)
44
+ ##
45
+ # @param name [String] registry key
46
+ # @param klass [Class]
47
+ # @return [Hash{Symbol => Hash}]
48
+ def post_processor_properties(name, klass)
49
+ properties = { name: { type: 'string', const: name } }
50
+ OptionSpec.for(klass).each do |spec|
51
+ properties[spec.name] = property_schema_for(spec.type)
52
+ end
53
+ properties
53
54
  end
54
- properties
55
- end
56
55
 
57
- ##
58
- # @param klass [Class]
59
- # @return [Array<String>]
60
- def post_processor_required(klass)
61
- required = ['name']
62
- required + OptionSpec.for(klass).select(&:required).map { |spec| spec.name.to_s }
63
- end
56
+ ##
57
+ # @param klass [Class]
58
+ # @return [Array<String>]
59
+ def post_processor_required(klass)
60
+ required = ['name']
61
+ required + OptionSpec.for(klass).select(&:required).map { |spec| spec.name.to_s }
62
+ end
64
63
 
65
- ##
66
- # @param ruby_type [Class, Array<Class>]
67
- # @return [Hash{Symbol => String, Array<String>}]
68
- def property_schema_for(ruby_type)
69
- { type: json_type_for(ruby_type) }
70
- end
64
+ ##
65
+ # @param ruby_type [Class, Array<Class>]
66
+ # @return [Hash{Symbol => String, Array<String>}]
67
+ def property_schema_for(ruby_type)
68
+ { type: json_type_for(ruby_type) }
69
+ end
71
70
 
72
- ##
73
- # @param ruby_type [Class, Array<Class>]
74
- # @return [String, Array<String>]
75
- def json_type_for(ruby_type)
76
- OptionSpec.json_type_for(ruby_type)
71
+ ##
72
+ # @param ruby_type [Class, Array<Class>]
73
+ # @return [String, Array<String>]
74
+ def json_type_for(ruby_type)
75
+ OptionSpec.json_type_for(ruby_type)
76
+ end
77
77
  end
78
78
  end
79
79
  end