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
@@ -5,32 +5,28 @@ require 'cgi'
5
5
  module Html2rss
6
6
  class Selectors
7
7
  ##
8
- # A naive implementation of "Object to XML": converts a Ruby object to XML format.
9
- class ObjectToXmlConverter
8
+ # Internal data converter helper: converts a Ruby object (e.g. parsed JSON response)
9
+ # to an XML format string so Nokogiri HTML5 CSS selectors can run on a fragment.
10
+ module JsonXml
10
11
  # Wrapper tags used for top-level collection conversion.
11
12
  OBJECT_TO_XML_TAGS = {
12
13
  hash: ['<object>', '</object>'],
13
14
  array: ['<array>', '</array>']
14
15
  }.freeze
15
16
 
16
- ##
17
- # @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
18
- def initialize(object)
19
- @object = object
20
- end
17
+ module_function
21
18
 
22
19
  ##
23
20
  # Converts the object to XML format.
24
21
  #
22
+ # @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
25
23
  # @return [String] representing the object in XML
26
- def call
27
- object_to_xml(@object).tap do |converted|
28
- Html2rss::Log.debug("#{self.class}: converted object to XML (#{converted.bytesize} bytes)")
24
+ def call(object)
25
+ object_to_xml(object).tap do |converted|
26
+ Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
29
27
  end
30
28
  end
31
29
 
32
- private
33
-
34
30
  def object_to_xml(object)
35
31
  case object
36
32
  when Hash
@@ -41,6 +37,7 @@ module Html2rss
41
37
  CGI.escapeHTML(object.to_s)
42
38
  end
43
39
  end
40
+ private_class_method :object_to_xml
44
41
 
45
42
  def hash_to_xml(object)
46
43
  prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
@@ -50,6 +47,7 @@ module Html2rss
50
47
 
51
48
  "#{prefix}#{inner_xml}#{suffix}"
52
49
  end
50
+ private_class_method :hash_to_xml
53
51
 
54
52
  def array_to_xml(object)
55
53
  prefix, suffix = OBJECT_TO_XML_TAGS[:array]
@@ -57,6 +55,7 @@ module Html2rss
57
55
 
58
56
  "#{prefix}#{inner_xml}#{suffix}"
59
57
  end
58
+ private_class_method :array_to_xml
60
59
  end
61
60
  end
62
61
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Selectors
5
+ OptionSpec = Data.define(:name, :type, :required)
6
+
7
+ ##
8
+ # One config-facing option on an extractor or post-processor strategy.
9
+ #
10
+ # Strategies own an +OPTIONS+ Array of these. This type is also the
11
+ # introspection SoT (+for+ / +expectation_for+); {SchemaExport} maps Ruby
12
+ # types to JSON Schema only.
13
+ class OptionSpec
14
+ # Maps Ruby option types to JSON Schema +type+ strings.
15
+ RUBY_TO_JSON_TYPE = {
16
+ String => 'string',
17
+ Integer => 'integer',
18
+ Hash => 'object',
19
+ Array => 'array'
20
+ }.freeze
21
+
22
+ # Human-readable labels for validation error messages.
23
+ TYPE_LABELS = {
24
+ Integer => 'an integer',
25
+ String => 'a string',
26
+ Hash => 'a hash'
27
+ }.freeze
28
+
29
+ # @param name [Symbol, String]
30
+ # @param type [Class, Array<Class>]
31
+ # @param required [Boolean]
32
+ def initialize(name:, type:, required: true)
33
+ super(name: name.to_sym, type:, required:)
34
+ end
35
+
36
+ ##
37
+ # @param actual [Object]
38
+ # @return [Boolean]
39
+ def valid_type?(actual)
40
+ self.class.valid_type?(actual, type)
41
+ end
42
+
43
+ ##
44
+ # @param optional [Boolean]
45
+ # @return [String]
46
+ def error_message(optional: !required)
47
+ suffix = optional ? ' or omitted' : ''
48
+ "`#{name}` must be #{type_failure_label}#{suffix}"
49
+ end
50
+
51
+ ##
52
+ # @return [String, Array<String>]
53
+ def json_type
54
+ self.class.json_type_for(type)
55
+ end
56
+
57
+ private
58
+
59
+ def type_failure_label
60
+ case type
61
+ when Array then type.map { |t| TYPE_LABELS.fetch(t) { "a #{t}" } }.join(' or ')
62
+ else TYPE_LABELS.fetch(type) { "a #{type}" }
63
+ end
64
+ end
65
+
66
+ class << self
67
+ ##
68
+ # @param actual [Object]
69
+ # @param type_or_types [Class, Array<Class>]
70
+ # @return [Boolean]
71
+ def valid_type?(actual, type_or_types)
72
+ case type_or_types
73
+ when Array then type_or_types.any? { |t| actual.is_a?(t) }
74
+ else actual.is_a?(type_or_types)
75
+ end
76
+ end
77
+
78
+ ##
79
+ # @param ruby_type [Class, Array<Class>]
80
+ # @return [String, Array<String>]
81
+ def json_type_for(ruby_type)
82
+ case ruby_type
83
+ when Array then ruby_type.map { |t| json_type_for(t) }
84
+ else
85
+ RUBY_TO_JSON_TYPE.fetch(ruby_type) do
86
+ raise ArgumentError, "unsupported OPTIONS type mapping for #{ruby_type}"
87
+ end
88
+ end
89
+ end
90
+
91
+ ##
92
+ # @param klass [Class] extractor or post-processor class
93
+ # @return [Array<OptionSpec>]
94
+ def for(klass)
95
+ klass::OPTIONS
96
+ end
97
+
98
+ ##
99
+ # Ruby-typed expectation for a leaf under a selector / post-process parent.
100
+ #
101
+ # @param parent [Hash] selector or post-process step hash
102
+ # @param leaf [Symbol] option key under +parent+
103
+ # @return [Hash{Symbol => Object}, nil] +{ type:, required: }+ with Ruby +type+
104
+ def expectation_for(parent:, leaf:)
105
+ option = option_for(parent, leaf)
106
+ return unless option
107
+
108
+ { type: option.type, required: option.required }
109
+ end
110
+
111
+ ##
112
+ # Resolves an OptionSpec for a parent hash and option leaf name.
113
+ #
114
+ # @param parent [Hash] selector or post-process step hash
115
+ # @param leaf [Symbol, String]
116
+ # @return [OptionSpec, nil]
117
+ def option_for(parent, leaf)
118
+ leaf = leaf.to_sym
119
+ if (name = parent[:extractor] || parent['extractor'])
120
+ klass = Extractors::NAME_TO_CLASS[name.to_sym]
121
+ return find_option(klass, leaf) if klass
122
+ end
123
+
124
+ if (name = parent[:name] || parent['name'])
125
+ klass = PostProcessors::NAME_TO_CLASS[name.to_sym]
126
+ return find_option(klass, leaf) if klass
127
+ end
128
+
129
+ nil
130
+ end
131
+
132
+ private
133
+
134
+ def find_option(klass, leaf)
135
+ self.for(klass).find { |spec| spec.name == leaf }
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -4,61 +4,109 @@ module Html2rss
4
4
  class Selectors
5
5
  module PostProcessors
6
6
  ##
7
- # All post processors must inherit from this base class and implement `self.validate_args!` and `#get`.
7
+ # All post processors must inherit from this base class and implement `#call`.
8
+ # Declare +VALUE_TYPE+ when the extracted value must be a fixed Ruby type
9
+ # (+nil+ when any type is accepted); override +validate_args!+ only for
10
+ # semantic checks beyond that. Every processor owns +OPTIONS+ (+[]+ when none).
8
11
  class Base
9
- # Validates the presence of required options in the context
10
- #
11
- # @param keys [Array<Symbol>] the keys to check for presence
12
- # @param context [Selectors::Context] the context containing options
13
- # @return [void]
14
- # @raise [MissingOption] if any key is missing
15
- def self.expect_options(keys, context)
16
- keys.each do |key|
17
- unless (options = context[:options]).key?(key)
18
- raise MissingOption, "The `#{key}` option is missing in: #{options.inspect}", [],
19
- cause: nil
20
- end
21
- end
22
- end
12
+ # Config-facing options contract (empty on the abstract base).
13
+ OPTIONS = [].freeze
14
+
15
+ # Expected Ruby class for the extracted value; +nil+ skips the type check.
16
+ VALUE_TYPE = nil
23
17
 
24
18
  # Asserts that the value is of the expected type(s)
25
19
  #
26
20
  # @param value [Object] the value to check
27
21
  # @param types [Array<Class>, Class] the expected type(s)
28
22
  # @param name [String] the name of the option being checked
29
- # @param context [Selectors::Context] call-site context used for richer validation errors
23
+ # @param context [Selectors::StepEnv] call-site context used for richer validation errors
30
24
  # @return [void]
31
25
  # @raise [InvalidType] if the value is not of the expected type(s)
32
26
  def self.assert_type(value, types, name, context:)
33
- return if Array(types).any? { |type| value.is_a?(type) }
27
+ return if Selectors::OptionSpec.valid_type?(value, types)
34
28
 
35
- options = if context.respond_to?(:options)
36
- context.options
37
- else
38
- { file: File.basename(caller(1, 1).first.split(':').first) }
39
- end
40
29
  message = "The type of `#{name}` must be #{Array(types).join(' or ')}, " \
41
- "but is: #{value.class} in: #{options.inspect}"
30
+ "but is: #{value.class} in: #{context.step_config.inspect}"
42
31
  raise InvalidType, message, [], cause: nil
43
32
  end
44
33
 
45
34
  ##
46
- # This method validates the arguments passed to the post processor. Must be implemented by subclasses.
35
+ # Validates presence and types from the strategy's +OPTIONS+ contract.
36
+ #
37
+ # @param context [Selectors::StepEnv]
38
+ # @return [void]
39
+ # @raise [MissingOption] if a required option key is absent
40
+ # @raise [InvalidType] if a present option has the wrong type
41
+ def self.validate_options!(context)
42
+ specs = option_specs
43
+ return if specs.empty?
44
+
45
+ step_config = context.step_config
46
+ cache = validated_step_configs
47
+ return if cache[step_config]
48
+
49
+ specs.each { |spec| validate_option_spec!(spec, step_config, context) }
50
+ cache[step_config] = true
51
+ end
52
+
53
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
54
+ def self.option_specs
55
+ @option_specs ||= OptionSpec.for(self)
56
+ end
57
+ private_class_method :option_specs
58
+
59
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
60
+ def self.validated_step_configs
61
+ @validated_step_configs ||= {}.compare_by_identity
62
+ end
63
+ private_class_method :validated_step_configs
64
+
65
+ ##
66
+ # @param spec [Selectors::OptionSpec]
67
+ # @param step_config [Hash] post-processor YAML step configuration
68
+ # @param context [Selectors::StepEnv]
69
+ # @return [void]
70
+ def self.validate_option_spec!(spec, step_config, context)
71
+ unless step_config.key?(spec.name)
72
+ return unless spec.required
73
+
74
+ raise MissingOption, "The `#{spec.name}` option is missing in: #{step_config.inspect}", [],
75
+ cause: nil
76
+ end
77
+
78
+ value = step_config[spec.name]
79
+ return if value.nil? && !spec.required
80
+
81
+ assert_type(value, spec.type, spec.name, context:)
82
+ end
83
+
84
+ ##
85
+ # Semantic / value checks beyond +VALUE_TYPE+ and the +OPTIONS+ contract.
86
+ # Override in subclasses that need non-type checks (non-empty, item_env, …).
47
87
  #
48
88
  # @param _value [Object] extracted selector value
49
- # @param _context [Selectors::Context] post-processor execution context
89
+ # @param _context [Selectors::StepEnv] post-processor execution context
50
90
  # @return [void]
51
91
  def self.validate_args!(_value, _context)
52
- raise NotImplementedError, 'You must implement the `validate_args!` method in the post processor'
92
+ # no-op default
53
93
  end
54
94
 
55
95
  # Initializes the post processor
56
96
  #
57
97
  # @param value [Object] the value to be processed
58
- # @param context [Selectors::Context] runtime selector context and options
59
- def initialize(value, context)
98
+ # @param context [Selectors::StepEnv] runtime selector context and options
99
+ # @raise [InvalidType] if +context+ is not a {Selectors::StepEnv} or +VALUE_TYPE+ mismatches
100
+ def initialize(value, context) # rubocop:disable Metrics/MethodLength -- type guard + VALUE_TYPE + validate_args
101
+ unless context.is_a?(Selectors::StepEnv)
102
+ raise InvalidType,
103
+ "The type of `context` must be #{Selectors::StepEnv}, but is: #{context.class}",
104
+ [], cause: nil
105
+ end
106
+
60
107
  klass = self.class
61
- klass.assert_type(context, Selectors::Context, 'context', context:)
108
+ klass.validate_options!(context)
109
+ klass.assert_type(value, klass::VALUE_TYPE, :value, context:) if klass::VALUE_TYPE
62
110
  klass.validate_args!(value, context)
63
111
 
64
112
  @value = value
@@ -71,8 +119,8 @@ module Html2rss
71
119
  #
72
120
  # @return [Object] transformed value
73
121
  # @raise [NotImplementedError] if not implemented in subclass
74
- def get
75
- raise NotImplementedError, 'You must implement the `get` method in the post processor'
122
+ def call
123
+ raise NotImplementedError, 'You must implement the `call` method in the post processor'
76
124
  end
77
125
  end
78
126
  end
@@ -29,15 +29,26 @@ module Html2rss
29
29
  #
30
30
  # See the doc on [String#gsub](https://ruby-doc.org/core/String.html#method-i-gsub) for more info.
31
31
  class Gsub < Base
32
- # Required config field types (validator introspection via +Options+).
33
- OPTION_TYPES = { pattern: String, replacement: String }.freeze
32
+ # Expected Ruby class for the extracted value before this post-processor runs.
33
+ VALUE_TYPE = String
34
34
 
35
- # Config fields required by this post-processor (validator / schema introspection).
36
- Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
35
+ # Config-facing options contract (validator / SchemaExport / Base.validate_options!).
36
+ OPTIONS = [
37
+ OptionSpec.new(name: :pattern, type: String),
38
+ OptionSpec.new(name: :replacement, type: [String, Hash])
39
+ ].freeze
40
+
41
+ # Character cap for the authored +pattern+ string (before slash stripping).
42
+ # Same unit as JSON Schema +maxLength+.
43
+ MAX_PATTERN_LENGTH = 256
37
44
 
38
- # JSON Schema description exported via +schema_doc+.
39
- DESCRIPTION = 'Replace matches of `pattern` in the extracted string with `replacement` ' \
40
- '(Ruby String#gsub; pattern may be a regexp-like string).'
45
+ # JSON Schema description exported via +schema_export+.
46
+ DESCRIPTION = format(
47
+ 'Replace matches of `pattern` in the extracted string with `replacement` ' \
48
+ '(Ruby String#gsub; pattern may be a regexp-like string). ' \
49
+ 'Patterns over %d characters as written, or with nested quantifiers, are rejected.',
50
+ MAX_PATTERN_LENGTH
51
+ ).freeze
41
52
 
42
53
  # Example post-process objects for JSON Schema +examples+.
43
54
  EXAMPLES = [
@@ -45,32 +56,101 @@ module Html2rss
45
56
  ].freeze
46
57
 
47
58
  # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
48
- def self.schema_doc = SchemaDoc.for_post_processor(name: :gsub, klass: self)
49
-
50
- # @param value [String] extracted selector value
51
- # @param context [Selectors::Context] post-processor context
52
- # @return [void]
53
- def self.validate_args!(value, context)
54
- assert_type value, String, :value, context:
55
- expect_options(%i[replacement pattern], context)
56
- assert_type context.dig(:options, :replacement), [String, Hash], :replacement, context:
59
+ def self.schema_export
60
+ fragment = SchemaExport.for_post_processor(name: :gsub, klass: self)
61
+ fragment.fetch(:properties).fetch(:pattern)[:maxLength] = MAX_PATTERN_LENGTH
62
+ fragment
63
+ end
64
+
65
+ ##
66
+ # Compiles and memoizes a gsub +pattern+ string as a Regexp.
67
+ #
68
+ # Raises before memoizing when the authored pattern exceeds
69
+ # {MAX_PATTERN_LENGTH} characters, its star-height is greater than 1, or it does not parse.
70
+ #
71
+ # @param string [String]
72
+ # @return [Regexp]
73
+ # @raise [ArgumentError] when +string+ is not a String, exceeds the character cap,
74
+ # contains nested quantifiers, or does not parse
75
+ def self.compiled_pattern(string)
76
+ compiled_patterns[string] ||= compile_regexp_string(string)
77
+ end
78
+
79
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
80
+ def self.compiled_patterns
81
+ @compiled_patterns ||= {}
82
+ end
83
+ private_class_method :compiled_patterns
84
+
85
+ # Parses the given String and builds a Regexp out of it.
86
+ #
87
+ # It will remove one pair of surrounding slashes ('/') from the String
88
+ # to maintain backwards compatibility before building the Regexp.
89
+ # Length is the character count of the authored string (the JSON Schema
90
+ # +maxLength+ unit), then the AST is walked for nested quantifiers,
91
+ # then +to_re+. Parser errors are re-raised as ArgumentError so
92
+ # validation and execution share one path.
93
+ #
94
+ # @param string [String]
95
+ # @return [Regexp]
96
+ # @raise [ArgumentError] when +string+ is not a String, exceeds {MAX_PATTERN_LENGTH},
97
+ # contains nested quantifiers, or does not parse
98
+ def self.compile_regexp_string(string)
99
+ raise ArgumentError, 'must be a string!' unless string.is_a?(String)
100
+ raise ArgumentError, "pattern exceeds #{MAX_PATTERN_LENGTH} characters" if string.length > MAX_PATTERN_LENGTH
101
+
102
+ source = regexp_source(string)
103
+
104
+ expression = Regexp::Parser.parse(source, options: ::Regexp::EXTENDED | ::Regexp::IGNORECASE)
105
+ raise ArgumentError, 'pattern contains nested quantifiers' if nested_quantifiers?(expression)
106
+
107
+ expression.to_re
108
+ rescue Regexp::Parser::Error => error
109
+ raise ArgumentError, error.message, [], cause: nil
110
+ end
111
+ private_class_method :compile_regexp_string
112
+
113
+ # Strips one pair of surrounding slashes when the source is long enough.
114
+ #
115
+ # @param string [String]
116
+ # @return [String]
117
+ def self.regexp_source(string)
118
+ return string unless string.length >= 3 && string.start_with?('/') && string.end_with?('/')
119
+
120
+ string[1..-2]
121
+ end
122
+ private_class_method :regexp_source
123
+
124
+ # Star-height greater than 1: a quantified node whose subtree contains another.
125
+ #
126
+ # +each_expression+ yields +(node, index)+. Do not pass +&:quantified?+:
127
+ # +Symbol#to_proc+ would forward the index into +quantified?+ (arity 0).
128
+ #
129
+ # @param expression [Regexp::Expression::Subexpression]
130
+ # @return [Boolean]
131
+ # rubocop:disable-next Style/SymbolProc
132
+ def self.nested_quantifiers?(expression)
133
+ expression.each_expression.any? do |node|
134
+ node.quantified? && !node.terminal? && node.each_expression.any? { |child| child.quantified? }
135
+ end
57
136
  end
137
+ private_class_method :nested_quantifiers?
58
138
 
59
139
  ##
60
140
  # @param value [String]
61
- # @param context [Selectors::Context]
141
+ # @param context [Selectors::StepEnv]
62
142
  def initialize(value, context)
63
143
  super
64
144
 
65
- options = context[:options]
145
+ step_config = context.step_config
66
146
 
67
- @replacement = options[:replacement]
68
- @pattern = options[:pattern]
147
+ @replacement = step_config[:replacement]
148
+ @pattern = step_config[:pattern]
69
149
  end
70
150
 
71
151
  ##
72
152
  # @return [String]
73
- def get
153
+ def call
74
154
  value.to_s.gsub(pattern, replacement)
75
155
  end
76
156
 
@@ -81,24 +161,8 @@ module Html2rss
81
161
  ##
82
162
  # @return [Regexp]
83
163
  def pattern
84
- @pattern.is_a?(String) ? parse_regexp_string(@pattern) : @pattern
85
- end
86
-
87
- ##
88
- # Parses the given String and builds a Regexp out of it.
89
- #
90
- # It will remove one pair of surrounding slashes ('/') from the String
91
- # to maintain backwards compatibility before building the Regexp.
92
- #
93
- # @param string [String]
94
- # @return [Regexp]
95
- def parse_regexp_string(string)
96
- raise ArgumentError, 'must be a string!' unless string.is_a?(String)
97
-
98
- # Only remove surrounding slashes if the string has at least 3 characters
99
- # to avoid issues with single character strings like "/"
100
- string = string[1..-2] if string.length >= 3 && string.start_with?('/') && string.end_with?('/')
101
- Regexp::Parser.parse(string, options: ::Regexp::EXTENDED | ::Regexp::IGNORECASE).to_re
164
+ raw = @pattern
165
+ raw.is_a?(String) ? self.class.compiled_pattern(raw) : raw
102
166
  end
103
167
  end
104
168
  end
@@ -17,9 +17,9 @@ module Html2rss
17
17
  'video' => :src # Video player is visible
18
18
  }.freeze
19
19
 
20
- # @param channel_url [String, Html2rss::Url] base URL used to resolve relative links
21
- def initialize(channel_url)
22
- @channel_url = channel_url
20
+ # @param base_url [String, Html2rss::Url] base URL used to resolve relative links
21
+ def initialize(base_url)
22
+ @base_url = base_url
23
23
  end
24
24
 
25
25
  ##
@@ -35,7 +35,7 @@ module Html2rss
35
35
 
36
36
  url_attribute = URL_ELEMENTS_WITH_URL_ATTRIBUTE[node_name]
37
37
  url = node[url_attribute]
38
- node[url_attribute] = Url.from_relative(url, @channel_url).to_s
38
+ node[url_attribute] = Url.from_relative(url, @base_url).to_s
39
39
  end
40
40
  end
41
41
  end
@@ -33,7 +33,13 @@ module Html2rss
33
33
  #
34
34
  # <p>Price: 12.34</p>
35
35
  class MarkdownToHtml < Base
36
- # JSON Schema description exported via +schema_doc+.
36
+ # Expected Ruby class for the extracted value before this post-processor runs.
37
+ VALUE_TYPE = String
38
+
39
+ # Config-facing options contract (none).
40
+ OPTIONS = [].freeze
41
+
42
+ # JSON Schema description exported via +schema_export+.
37
43
  DESCRIPTION = 'Convert Markdown to HTML (Kramdown) and sanitize the result. ' \
38
44
  'Often chained after `template`.'
39
45
 
@@ -43,22 +49,15 @@ module Html2rss
43
49
  ].freeze
44
50
 
45
51
  # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
46
- def self.schema_doc = SchemaDoc.for_post_processor(name: :markdown_to_html, klass: self)
47
-
48
- # @param value [String] extracted selector value
49
- # @param context [Selectors::Context] post-processor context
50
- # @return [void]
51
- def self.validate_args!(value, context)
52
- assert_type value, String, :value, context:
53
- end
52
+ def self.schema_export = SchemaExport.for_post_processor(name: :markdown_to_html, klass: self)
54
53
 
55
54
  ##
56
55
  # Converts Markdown to sanitized HTML.
57
56
  #
58
57
  # @return [String] Sanitized HTML content
59
- def get
58
+ def call
60
59
  html_content = Kramdown::Document.new(value).to_html
61
- SanitizeHtml.new(html_content, context).get
60
+ SanitizeHtml.new(html_content, context).call
62
61
  end
63
62
  end
64
63
  end