html2rss 0.30.0 → 0.31.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +1 -1
  6. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  10. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  11. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  13. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  14. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  16. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  17. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper.rb +4 -14
  19. data/lib/html2rss/auto_source/segment.rb +7 -4
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  21. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  22. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  23. data/lib/html2rss/auto_source.rb +2 -2
  24. data/lib/html2rss/batch.rb +3 -1
  25. data/lib/html2rss/capture/README.md +6 -4
  26. data/lib/html2rss/capture.rb +67 -35
  27. data/lib/html2rss/channel.rb +3 -3
  28. data/lib/html2rss/cli/probe_view.rb +5 -3
  29. data/lib/html2rss/cli/render.rb +6 -5
  30. data/lib/html2rss/cli/validate.rb +28 -27
  31. data/lib/html2rss/cli.rb +1 -1
  32. data/lib/html2rss/config/issue_mapper.rb +108 -0
  33. data/lib/html2rss/config/request_headers.rb +2 -3
  34. data/lib/html2rss/config/schema.rb +16 -1
  35. data/lib/html2rss/config/selectors_validator.rb +92 -70
  36. data/lib/html2rss/config/validation_issue.rb +60 -0
  37. data/lib/html2rss/config/validation_report.rb +55 -0
  38. data/lib/html2rss/config/validator.rb +10 -6
  39. data/lib/html2rss/config.rb +35 -113
  40. data/lib/html2rss/defaults.rb +42 -30
  41. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  42. data/lib/html2rss/doctor.rb +8 -0
  43. data/lib/html2rss/error.rb +1 -1
  44. data/lib/html2rss/feed_builder/rss.rb +9 -14
  45. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  46. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  48. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  49. data/lib/html2rss/feed_resolution/options.rb +3 -1
  50. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  51. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  52. data/lib/html2rss/html/article_extractor.rb +2 -4
  53. data/lib/html2rss/html/feed_link.rb +3 -1
  54. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  55. data/lib/html2rss/html/navigator.rb +10 -10
  56. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  57. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  58. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  59. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  60. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  61. data/lib/html2rss/mcp/README.md +2 -0
  62. data/lib/html2rss/mcp/config_argument.rb +3 -1
  63. data/lib/html2rss/mcp/contract.rb +6 -20
  64. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  65. data/lib/html2rss/mcp/outcome.rb +36 -43
  66. data/lib/html2rss/mcp/server/tools.rb +111 -131
  67. data/lib/html2rss/mcp/server.rb +26 -18
  68. data/lib/html2rss/page_recon/README.md +5 -5
  69. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  70. data/lib/html2rss/page_recon.rb +9 -5
  71. data/lib/html2rss/recon.rb +19 -30
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  74. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  75. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  76. data/lib/html2rss/request_service/network_guard.rb +7 -5
  77. data/lib/html2rss/request_service/response.rb +1 -2
  78. data/lib/html2rss/request_service/strategy.rb +3 -8
  79. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  80. data/lib/html2rss/scoring/engine.rb +8 -8
  81. data/lib/html2rss/scoring/observation.rb +5 -3
  82. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  83. data/lib/html2rss/scrape_target.rb +3 -1
  84. data/lib/html2rss/selector_candidates.rb +234 -0
  85. data/lib/html2rss/selectors/README.md +64 -0
  86. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  87. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  88. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  89. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  90. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  91. data/lib/html2rss/selectors/extractors.rb +33 -20
  92. data/lib/html2rss/selectors/item_env.rb +31 -0
  93. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  94. data/lib/html2rss/selectors/option_spec.rb +140 -0
  95. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  96. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  97. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  98. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  99. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  100. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  101. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  102. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  103. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  104. data/lib/html2rss/selectors/post_processors.rb +6 -5
  105. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  106. data/lib/html2rss/selectors/step_env.rb +24 -0
  107. data/lib/html2rss/selectors.rb +231 -142
  108. data/lib/html2rss/sst/attrs.rb +5 -3
  109. data/lib/html2rss/sst/document.rb +3 -1
  110. data/lib/html2rss/sst/index.rb +3 -1
  111. data/lib/html2rss/sst/node.rb +29 -10
  112. data/lib/html2rss/sst/normalizer.rb +12 -16
  113. data/lib/html2rss/sst/tags.rb +7 -7
  114. data/lib/html2rss/status.rb +13 -8
  115. data/lib/html2rss/surface_category.rb +14 -32
  116. data/lib/html2rss/syndication/parser.rb +2 -2
  117. data/lib/html2rss/test/enhance_audit.rb +1 -3
  118. data/lib/html2rss/test/policy.rb +106 -0
  119. data/lib/html2rss/test.rb +126 -173
  120. data/lib/html2rss/url.rb +25 -4
  121. data/lib/html2rss/version.rb +1 -1
  122. data/lib/html2rss.rb +21 -13
  123. data/lib/tasks/config_schema.rake +2 -2
  124. data/schema/html2rss-config.schema.json +11 -8
  125. metadata +15 -6
  126. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
3
4
  require 'time'
4
5
  require 'tzinfo'
5
6
 
@@ -27,7 +28,13 @@ module Html2rss
27
28
  #
28
29
  # It uses `Time.parse`.
29
30
  class ParseTime < Base
30
- # JSON Schema description exported via +schema_doc+.
31
+ # Expected Ruby class for the extracted value before this post-processor runs.
32
+ VALUE_TYPE = String
33
+
34
+ # Config-facing options contract (none).
35
+ OPTIONS = [].freeze
36
+
37
+ # JSON Schema description exported via +schema_export+.
31
38
  DESCRIPTION = 'Parse a time string with Time.parse and return RFC822, using the channel `time_zone`.'
32
39
 
33
40
  # Example post-process objects for JSON Schema +examples+.
@@ -36,14 +43,29 @@ module Html2rss
36
43
  ].freeze
37
44
 
38
45
  # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
39
- def self.schema_doc = SchemaDoc.for_post_processor(name: :parse_time, klass: self)
46
+ def self.schema_export = SchemaExport.for_post_processor(name: :parse_time, klass: self)
47
+
48
+ # @param identifier [String] IANA time zone name
49
+ # @return [TZInfo::Timezone]
50
+ def self.timezone(identifier)
51
+ timezones[identifier] ||= TZInfo::Timezone.get(identifier)
52
+ end
40
53
 
41
- # @param value [String] extracted selector value
42
- # @param context [Selectors::Context] post-processor context
54
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
55
+ def self.timezones
56
+ @timezones ||= {}
57
+ end
58
+ private_class_method :timezones
59
+
60
+ ##
61
+ # Ensures +context.time_zone+ is a non-empty String before parsing.
62
+ #
63
+ # @param _value [String] extracted time string (unused; type-checked via VALUE_TYPE)
64
+ # @param context [Selectors::StepEnv] must carry a usable +time_zone+
43
65
  # @return [void]
44
- def self.validate_args!(value, context)
45
- assert_type(value, String, :value, context:)
46
- time_zone_value = time_zone(context)
66
+ # @raise [ArgumentError] when time_zone is nil or empty
67
+ def self.validate_args!(_value, context)
68
+ time_zone_value = context.time_zone
47
69
 
48
70
  if time_zone_value.nil? || time_zone_value.empty?
49
71
  raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil
@@ -52,36 +74,38 @@ module Html2rss
52
74
  assert_type(time_zone_value, String, :time_zone, context:)
53
75
  end
54
76
 
55
- # @param context [Selectors::Context] post-processor context
56
- # @return [String, nil] configured channel time zone
57
- def self.time_zone(context) = context.dig(:config, :channel, :time_zone)
58
-
59
77
  ##
60
78
  # Converts the provided time string to RFC822 format, taking into account the time_zone.
61
79
  #
80
+ # Uses in-memory {TZInfo::Timezone} conversion so parsing never mutates +ENV['TZ']+
81
+ # (which would invalidate libc +tzset+ and re-read zoneinfo from disk).
82
+ #
62
83
  # @return [String] RFC822 formatted time
63
84
  # @raise [TZInfo::InvalidTimezoneIdentifier] if the configured time zone is invalid
64
- def get
65
- with_timezone(time_zone) { Time.parse(value).rfc822 }
85
+ def call
86
+ parse_time(value, context.time_zone).rfc822
66
87
  end
67
88
 
68
89
  private
69
90
 
70
- def time_zone
71
- self.class.time_zone(context)
91
+ def parse_time(string, time_zone)
92
+ components = Date._parse(string, false)
93
+ if components[:zone] || components.key?(:offset)
94
+ Time.parse(string)
95
+ else
96
+ local_time_in_zone(components, time_zone)
97
+ end
72
98
  end
73
99
 
74
- def with_timezone(time_zone)
75
- return yield if time_zone.nil? || time_zone.empty?
76
-
77
- # Validate timezone using TZInfo
78
- TZInfo::Timezone.get(time_zone)
79
-
80
- prev_tz = ENV.fetch('TZ', Time.now.getlocal.zone)
81
- ENV['TZ'] = time_zone
82
- yield
83
- ensure
84
- ENV['TZ'] = prev_tz if prev_tz
100
+ def local_time_in_zone(components, time_zone)
101
+ self.class.timezone(time_zone).local_time(
102
+ components[:year],
103
+ components[:mon],
104
+ components[:mday],
105
+ components[:hour] || 0,
106
+ components[:min] || 0,
107
+ (components[:sec] || 0) + (components[:sec_fraction] || 0)
108
+ )
85
109
  end
86
110
  end
87
111
  end
@@ -23,7 +23,13 @@ module Html2rss
23
23
  # Would return:
24
24
  # 'http://why-not-use-a-link.uh'
25
25
  class ParseUri < Base
26
- # JSON Schema description exported via +schema_doc+.
26
+ # Expected Ruby class for the extracted value; any type is accepted.
27
+ VALUE_TYPE = nil
28
+
29
+ # Config-facing options contract (none).
30
+ OPTIONS = [].freeze
31
+
32
+ # JSON Schema description exported via +schema_export+.
27
33
  DESCRIPTION = 'Normalize a URL string; resolve relative URLs against the channel URL.'
28
34
 
29
35
  # Example post-process objects for JSON Schema +examples+.
@@ -32,10 +38,10 @@ module Html2rss
32
38
  ].freeze
33
39
 
34
40
  # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
35
- def self.schema_doc = SchemaDoc.for_post_processor(name: :parse_uri, klass: self)
41
+ def self.schema_export = SchemaExport.for_post_processor(name: :parse_uri, klass: self)
36
42
 
37
43
  # @param value [String] extracted selector value
38
- # @param _context [Selectors::Context] post-processor context
44
+ # @param _context [Selectors::StepEnv] post-processor context
39
45
  # @return [void]
40
46
  def self.validate_args!(value, _context)
41
47
  raise ArgumentError, 'The `value` option is missing or empty.' if value.to_s.empty?
@@ -43,10 +49,8 @@ module Html2rss
43
49
 
44
50
  ##
45
51
  # @return [String]
46
- def get
47
- config_url = context.dig(:config, :channel, :url)
48
-
49
- Url.from_relative(value, config_url).to_s
52
+ def call
53
+ Url.from_relative(value, context.base_url).to_s
50
54
  end
51
55
  end
52
56
  end
@@ -38,7 +38,13 @@ module Html2rss
38
38
  # Would return:
39
39
  # '<p>Lorem <b>ipsum</b> dolor ...</p>'
40
40
  class SanitizeHtml < Base
41
- # JSON Schema description exported via +schema_doc+.
41
+ # Expected Ruby class for the extracted value before this post-processor runs.
42
+ VALUE_TYPE = String
43
+
44
+ # Config-facing options contract (none).
45
+ OPTIONS = [].freeze
46
+
47
+ # JSON Schema description exported via +schema_export+.
42
48
  DESCRIPTION = 'Sanitize HTML (sanitize gem RELAXED plus html2rss defaults: absolute URLs, ' \
43
49
  'safe link/img attributes, wrap lone images in anchors).'
44
50
 
@@ -92,21 +98,18 @@ module Html2rss
92
98
  }.freeze
93
99
 
94
100
  # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
95
- def self.schema_doc = SchemaDoc.for_post_processor(name: :sanitize_html, klass: self)
96
-
97
- # @param value [String] extracted selector value
98
- # @param context [Selectors::Context] post-processor context
99
- # @return [void]
100
- def self.validate_args!(value, context)
101
- assert_type value, String, :value, context:
102
- end
101
+ def self.schema_export = SchemaExport.for_post_processor(name: :sanitize_html, klass: self)
103
102
 
104
103
  ##
105
- # @param html [String]
106
- # @param url [String, Html2rss::Url]
107
- # @return [String, nil]
104
+ # Cached standalone helper: sanitizes an HTML string with a memoized fragment cache.
105
+ # Used outside the selector pipeline (e.g. by DescriptionBuilder).
106
+ # Distinct from {#call}, which executes as a post-processor strategy instance.
107
+ #
108
+ # @param html [String] raw HTML text to sanitize
109
+ # @param url [String, Html2rss::Url] base URL for relative link/image resolution
110
+ # @return [String, nil] sanitized HTML string or nil if empty
108
111
  # rubocop:disable-next ThreadSafety/ClassInstanceVariable
109
- def self.get(html, url)
112
+ def self.call(html, url)
110
113
  return nil if String(html).empty?
111
114
 
112
115
  @fragment_cache ||= {}
@@ -114,24 +117,45 @@ module Html2rss
114
117
  return @fragment_cache[key] if @fragment_cache.key?(key)
115
118
 
116
119
  @fragment_cache.clear if @fragment_cache.size > 256
117
- context = Selectors::Context.new(config: { channel: { url: } }, options: {})
118
- @fragment_cache[key] = new(html, context).get
120
+ @fragment_cache[key] = sanitize(html, url)
119
121
  end
120
122
 
121
123
  ##
122
- # @param channel_url [String, Html2rss::Url]
124
+ # Pure HTML sanitization with preserved newlines and absolute URL resolution.
125
+ #
126
+ # @param html [String]
127
+ # @param base_url [String, Html2rss::Url]
128
+ # @return [String, nil]
129
+ def self.sanitize(html, base_url)
130
+ return nil if String(html).empty?
131
+
132
+ # Temporarily replace newlines with a placeholder to preserve them during space collapsing
133
+ temp_value = html.to_s.gsub("\n", ' __NEWLINE_PLACEHOLDER__ ')
134
+ sanitized_html = Sanitize.fragment(temp_value, sanitize_config(base_url)).to_s
135
+ sanitized_html.gsub!(/\s+/, ' ')
136
+
137
+ # Restore newlines and clean up surrounding whitespace
138
+ sanitized_html.gsub!(/[ \t\r]*__NEWLINE_PLACEHOLDER__[ \t\r]*/, "\n")
139
+ sanitized_html.gsub!(/\n{3,}/, "\n\n")
140
+
141
+ sanitized_html.strip!
142
+ sanitized_html.empty? ? nil : sanitized_html
143
+ end
144
+
145
+ ##
146
+ # @param base_url [String, Html2rss::Url]
123
147
  # @return [Hash] the memoized sanitize configuration
124
148
  # rubocop:disable-next Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
125
- def self.sanitize_config(channel_url)
149
+ def self.sanitize_config(base_url)
126
150
  @sanitize_configs ||= {}
127
- @sanitize_configs[channel_url] ||= begin
151
+ @sanitize_configs[base_url] ||= begin
128
152
  config = Sanitize::Config.merge(
129
153
  Sanitize::Config::RELAXED,
130
154
  attributes: { all: %w[dir lang alt title translate] },
131
155
  add_attributes: TAG_ATTRIBUTES,
132
156
  transformers: [
133
157
  lambda { |env|
134
- HtmlTransformers::TransformUrlsToAbsoluteOnes.new(channel_url).call(**env)
158
+ HtmlTransformers::TransformUrlsToAbsoluteOnes.new(base_url).call(**env)
135
159
  },
136
160
  ->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
137
161
  ]
@@ -145,23 +169,13 @@ module Html2rss
145
169
 
146
170
  ##
147
171
  # @return [String, nil]
148
- def get
149
- # Temporarily replace newlines with a placeholder to preserve them during space collapsing
150
- temp_value = value.to_s.gsub("\n", ' __NEWLINE_PLACEHOLDER__ ')
151
- sanitized_html = Sanitize.fragment(temp_value, self.class.sanitize_config(channel_url)).to_s
152
- sanitized_html.gsub!(/\s+/, ' ')
153
-
154
- # Restore newlines and clean up surrounding whitespace
155
- sanitized_html.gsub!(/[ \t\r]*__NEWLINE_PLACEHOLDER__[ \t\r]*/, "\n")
156
- sanitized_html.gsub!(/\n{3,}/, "\n\n")
157
-
158
- sanitized_html.strip!
159
- sanitized_html.empty? ? nil : sanitized_html
172
+ def call
173
+ self.class.sanitize(value, base_url)
160
174
  end
161
175
 
162
176
  private
163
177
 
164
- def channel_url = context.dig(:config, :channel, :url)
178
+ def base_url = context.base_url
165
179
  end
166
180
  end
167
181
  end
@@ -30,16 +30,16 @@ module Html2rss
30
30
  # Would return:
31
31
  # 'bar'
32
32
  class Substring < Base
33
- # Required config field types (validator introspection via +Options+).
34
- OPTION_TYPES = { start: Integer }.freeze
33
+ # Expected Ruby class for the extracted value before this post-processor runs.
34
+ VALUE_TYPE = String
35
35
 
36
- # Optional config field types (validated when the key is present and non-nil).
37
- OPTIONAL_OPTION_TYPES = { end: Integer }.freeze
38
-
39
- # Config fields required by this post-processor (validator / schema introspection).
40
- Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
36
+ # Config-facing options contract (validator / SchemaExport / Base.validate_options!).
37
+ OPTIONS = [
38
+ OptionSpec.new(name: :start, type: Integer),
39
+ OptionSpec.new(name: :end, type: Integer, required: false)
40
+ ].freeze
41
41
 
42
- # JSON Schema description exported via +schema_doc+.
42
+ # JSON Schema description exported via +schema_export+.
43
43
  DESCRIPTION = 'Return a slice of the extracted string using Integer `start` and optional `end` ' \
44
44
  '(Ruby String#[] range semantics; end may be omitted).'
45
45
 
@@ -49,24 +49,13 @@ module Html2rss
49
49
  ].freeze
50
50
 
51
51
  # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
52
- def self.schema_doc = SchemaDoc.for_post_processor(name: :substring, klass: self)
53
-
54
- # @param value [String] extracted selector value
55
- # @param context [Selectors::Context] post-processor context
56
- # @return [void]
57
- def self.validate_args!(value, context)
58
- assert_type(value, String, :value, context:)
59
-
60
- options = context[:options]
61
- assert_type(options[:start], Integer, :start, context:)
62
- assert_type(options[:end], Integer, :end, context:) if options.key?(:end)
63
- end
52
+ def self.schema_export = SchemaExport.for_post_processor(name: :substring, klass: self)
64
53
 
65
54
  ##
66
55
  # Extracts the substring from the original string based on the provided start and end indices.
67
56
  #
68
57
  # @return [String, nil] The extracted substring.
69
- def get
58
+ def call
70
59
  value[range]
71
60
  end
72
61
 
@@ -75,12 +64,12 @@ module Html2rss
75
64
  #
76
65
  # @return [Range] The range object representing the start and end/Infinity (integers).
77
66
  def range
78
- options = context[:options]
79
- start = options[:start]
67
+ step_config = context.step_config
68
+ start = step_config[:start]
80
69
 
81
- return (start..) unless options.key?(:end)
70
+ return (start..) unless step_config.key?(:end)
82
71
 
83
- finish = options[:end]
72
+ finish = step_config[:end]
84
73
  raise ArgumentError, 'The `start` value must be unequal to the `end` value.' if start == finish
85
74
 
86
75
  (start..finish)
@@ -34,13 +34,15 @@ module Html2rss
34
34
  # Would return:
35
35
  # 'Product (23,42€)'
36
36
  class Template < Base
37
- # Required config field types (validator introspection via +Options+).
38
- OPTION_TYPES = { string: String }.freeze
37
+ # Expected Ruby class for the extracted value before this post-processor runs.
38
+ VALUE_TYPE = String
39
39
 
40
- # Config fields required by this post-processor (validator / schema introspection).
41
- Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
40
+ # Config-facing options contract (validator / SchemaExport / Base.validate_options!).
41
+ OPTIONS = [
42
+ OptionSpec.new(name: :string, type: String)
43
+ ].freeze
42
44
 
43
- # JSON Schema description exported via +schema_doc+.
45
+ # JSON Schema description exported via +schema_export+.
44
46
  # rubocop:disable Style/FormatStringToken -- documents Kernel#format `%{key}` placeholders
45
47
  DESCRIPTION = 'Format a string with Kernel#format-style placeholders (`%{key}` / `%<key>s`). ' \
46
48
  '`%{self}` is the current selector value; other keys resolve sibling selectors.'
@@ -52,36 +54,38 @@ module Html2rss
52
54
  # rubocop:enable Style/FormatStringToken
53
55
 
54
56
  # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
55
- def self.schema_doc = SchemaDoc.for_post_processor(name: :template, klass: self)
57
+ def self.schema_export = SchemaExport.for_post_processor(name: :template, klass: self)
56
58
 
57
- # @param value [String] extracted selector value
58
- # @param context [Selectors::Context] post-processor context
59
+ ##
60
+ # Ensures a non-empty template +string+ option and an +item_env+ for sibling lookup.
61
+ #
62
+ # @param _value [String] current selector value (unused here)
63
+ # @param context [Selectors::StepEnv] must include step_config[:string] and item_env
59
64
  # @return [void]
60
- def self.validate_args!(value, context)
61
- assert_type value, String, :value, context:
62
-
63
- string = context[:options]&.dig(:string).to_s
65
+ # @raise [InvalidType] when the template string is blank
66
+ # @raise [MissingOption] when item_env is missing
67
+ def self.validate_args!(_value, context)
68
+ string = context.step_config[:string].to_s
64
69
  raise InvalidType, 'The `string` template is absent.' if string.empty?
65
70
 
66
- return if context.item_scope
71
+ return if context.item_env
67
72
 
68
- raise MissingOption, 'The post-processor context is missing `item_scope`.', [], cause: nil
73
+ raise MissingOption, 'The post-processor context is missing `item_env`.', [], cause: nil
69
74
  end
70
75
 
71
76
  ##
72
77
  # @param value [String]
73
- # @param context [Selectors::Context]
78
+ # @param context [Selectors::StepEnv]
74
79
  def initialize(value, context)
75
80
  super
76
81
 
77
- @options = context[:options] || {}
78
- @string = @options[:string].to_s
82
+ @string = context.step_config[:string].to_s
79
83
  @getter = ->(key) { item_value(key) }
80
84
  end
81
85
 
82
86
  ##
83
87
  # @return [String]
84
- def get
88
+ def call
85
89
  Html2rss::Config::DynamicParams.call(@string, {}, getter: @getter, replace_missing_with: '')
86
90
  end
87
91
 
@@ -93,7 +97,7 @@ module Html2rss
93
97
  key = key.to_sym
94
98
  return value if key == :self
95
99
 
96
- @context.item_scope.select(key)
100
+ @context.item_env.select(key)
97
101
  end
98
102
  end
99
103
  end
@@ -3,7 +3,7 @@
3
3
  module Html2rss
4
4
  class Selectors
5
5
  ##
6
- # Provides a namespace for attribute post processors.
6
+ # Provides a namespace for selector post processors.
7
7
  module PostProcessors
8
8
  ##
9
9
  # Error raised when an unknown post processor name is requested.
@@ -32,15 +32,16 @@ module Html2rss
32
32
  }.freeze
33
33
 
34
34
  ##
35
- # Shorthand method to instantiate the post processor and call `#get` on it
35
+ # Registry dispatcher: resolves the post-processor strategy for +name+,
36
+ # instantiates it with +(value, context)+, and executes its +#call+.
36
37
  #
37
38
  # @param name [String, Symbol] post-processor name from selector config
38
39
  # @param value [Object] extracted selector value
39
- # @param context [Selectors::Context] post-processor context
40
+ # @param context [Selectors::StepEnv] post-processor step context carrying +step_config+
40
41
  # @return [Object] transformed selector value
41
- def self.get(name, value, context)
42
+ def self.call(name, value, context)
42
43
  klass = NAME_TO_CLASS[name.to_sym] || raise(UnknownPostProcessorName, "Unknown name '#{name}'")
43
- klass.new(value, context).get
44
+ klass.new(value, context).call
44
45
  end
45
46
  end
46
47
  end
@@ -5,17 +5,9 @@ module Html2rss
5
5
  ##
6
6
  # Builds JSON Schema fragments from extractor / post-processor class constants.
7
7
  #
8
- # Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTION_TYPES+;
8
+ # Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTIONS+;
9
9
  # this module is the export adapter only.
10
- module SchemaDoc
11
- # Maps Ruby option types to JSON Schema +type+ strings.
12
- RUBY_TO_JSON_TYPE = {
13
- String => 'string',
14
- Integer => 'integer',
15
- Hash => 'object',
16
- Array => 'array'
17
- }.freeze
18
-
10
+ module SchemaExport
19
11
  module_function
20
12
 
21
13
  ##
@@ -56,44 +48,33 @@ module Html2rss
56
48
  # @return [Hash{Symbol => Hash}]
57
49
  def post_processor_properties(name, klass)
58
50
  properties = { name: { type: 'string', const: name } }
59
- option_types_for(klass).each do |field, ruby_type|
60
- properties[field] = { type: json_type_for(ruby_type) }
51
+ OptionSpec.for(klass).each do |spec|
52
+ properties[spec.name] = property_schema_for(spec.type)
61
53
  end
62
54
  properties
63
55
  end
64
- module_function :post_processor_properties
65
56
 
66
57
  ##
67
58
  # @param klass [Class]
68
59
  # @return [Array<String>]
69
60
  def post_processor_required(klass)
70
61
  required = ['name']
71
- return required unless klass.const_defined?(:OPTION_TYPES)
72
-
73
- required + klass::OPTION_TYPES.keys.map(&:to_s)
62
+ required + OptionSpec.for(klass).select(&:required).map { |spec| spec.name.to_s }
74
63
  end
75
- module_function :post_processor_required
76
64
 
77
65
  ##
78
- # @param klass [Class]
79
- # @return [Hash{Symbol => Class}]
80
- def option_types_for(klass)
81
- types = {}
82
- types.merge!(klass::OPTION_TYPES) if klass.const_defined?(:OPTION_TYPES)
83
- types.merge!(klass::OPTIONAL_OPTION_TYPES) if klass.const_defined?(:OPTIONAL_OPTION_TYPES)
84
- types
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) }
85
70
  end
86
- module_function :option_types_for
87
71
 
88
72
  ##
89
- # @param ruby_type [Class]
90
- # @return [String]
73
+ # @param ruby_type [Class, Array<Class>]
74
+ # @return [String, Array<String>]
91
75
  def json_type_for(ruby_type)
92
- RUBY_TO_JSON_TYPE.fetch(ruby_type) do
93
- raise ArgumentError, "unsupported OPTION_TYPES mapping for #{ruby_type}"
94
- end
76
+ OptionSpec.json_type_for(ruby_type)
95
77
  end
96
- module_function :json_type_for
97
78
  end
98
79
  end
99
80
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Selectors
5
+ StepEnv = Data.define(:step_config, :base_url, :time_zone, :item_env)
6
+
7
+ # Invocation environment passed to post-processors.
8
+ # When built via {ItemEnv#context_for}, +item_env+ carries the per-item
9
+ # extraction base_url for nested selects (e.g. Template).
10
+ class StepEnv
11
+ ##
12
+ # @param step_config [Hash] post-processor configuration (YAML step)
13
+ # @param base_url [String, Html2rss::Url, nil] page URL for relative resolution
14
+ # @param time_zone [String, nil] channel time zone for date parsing
15
+ # @param item_env [ItemEnv, nil]
16
+ # @option step_config [String] :name post-processor name
17
+ def initialize(step_config: {}.freeze, base_url: nil, time_zone: nil, item_env: nil)
18
+ base_url ||= item_env&.base_url
19
+ time_zone ||= item_env&.time_zone
20
+ super
21
+ end
22
+ end
23
+ end
24
+ end