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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +1 -1
- data/lib/html2rss/auto_source/cleanup.rb +15 -6
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -14
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +2 -2
- data/lib/html2rss/batch.rb +3 -1
- data/lib/html2rss/capture/README.md +6 -4
- data/lib/html2rss/capture.rb +67 -35
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +1 -1
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +2 -3
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +35 -113
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +6 -4
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +2 -0
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +6 -20
- data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +111 -131
- data/lib/html2rss/mcp/server.rb +26 -18
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +19 -30
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/compressed_body.rb +4 -1
- data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/strategy.rb +3 -8
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -173
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +21 -13
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +15 -6
- 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
|
-
#
|
|
9
|
-
|
|
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(
|
|
28
|
-
Html2rss::Log.debug("#{self
|
|
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
|
|
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
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
#
|
|
13
|
-
|
|
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::
|
|
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
|
|
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: #{
|
|
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
|
-
#
|
|
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::
|
|
89
|
+
# @param _context [Selectors::StepEnv] post-processor execution context
|
|
50
90
|
# @return [void]
|
|
51
91
|
def self.validate_args!(_value, _context)
|
|
52
|
-
|
|
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::
|
|
59
|
-
|
|
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.
|
|
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
|
|
75
|
-
raise NotImplementedError, 'You must implement the `
|
|
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
|
-
#
|
|
33
|
-
|
|
32
|
+
# Expected Ruby class for the extracted value before this post-processor runs.
|
|
33
|
+
VALUE_TYPE = String
|
|
34
34
|
|
|
35
|
-
# Config
|
|
36
|
-
|
|
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 +
|
|
39
|
-
DESCRIPTION =
|
|
40
|
-
|
|
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.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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::
|
|
141
|
+
# @param context [Selectors::StepEnv]
|
|
62
142
|
def initialize(value, context)
|
|
63
143
|
super
|
|
64
144
|
|
|
65
|
-
|
|
145
|
+
step_config = context.step_config
|
|
66
146
|
|
|
67
|
-
@replacement =
|
|
68
|
-
@pattern =
|
|
147
|
+
@replacement = step_config[:replacement]
|
|
148
|
+
@pattern = step_config[:pattern]
|
|
69
149
|
end
|
|
70
150
|
|
|
71
151
|
##
|
|
72
152
|
# @return [String]
|
|
73
|
-
def
|
|
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
|
-
|
|
85
|
-
|
|
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
|
data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb
CHANGED
|
@@ -17,9 +17,9 @@ module Html2rss
|
|
|
17
17
|
'video' => :src # Video player is visible
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
|
-
# @param
|
|
21
|
-
def initialize(
|
|
22
|
-
@
|
|
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, @
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
58
|
+
def call
|
|
60
59
|
html_content = Kramdown::Document.new(value).to_html
|
|
61
|
-
SanitizeHtml.new(html_content, context).
|
|
60
|
+
SanitizeHtml.new(html_content, context).call
|
|
62
61
|
end
|
|
63
62
|
end
|
|
64
63
|
end
|