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
|
@@ -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
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
time_zone_value = time_zone
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
#
|
|
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.
|
|
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::
|
|
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
|
|
47
|
-
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
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.
|
|
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
|
-
|
|
118
|
-
@fragment_cache[key] = new(html, context).get
|
|
120
|
+
@fragment_cache[key] = sanitize(html, url)
|
|
119
121
|
end
|
|
120
122
|
|
|
121
123
|
##
|
|
122
|
-
#
|
|
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(
|
|
149
|
+
def self.sanitize_config(base_url)
|
|
126
150
|
@sanitize_configs ||= {}
|
|
127
|
-
@sanitize_configs[
|
|
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(
|
|
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
|
|
149
|
-
|
|
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
|
|
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
|
-
#
|
|
34
|
-
|
|
33
|
+
# Expected Ruby class for the extracted value before this post-processor runs.
|
|
34
|
+
VALUE_TYPE = String
|
|
35
35
|
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 +
|
|
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.
|
|
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
|
|
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
|
-
|
|
79
|
-
start =
|
|
67
|
+
step_config = context.step_config
|
|
68
|
+
start = step_config[:start]
|
|
80
69
|
|
|
81
|
-
return (start..) unless
|
|
70
|
+
return (start..) unless step_config.key?(:end)
|
|
82
71
|
|
|
83
|
-
finish =
|
|
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
|
-
#
|
|
38
|
-
|
|
37
|
+
# Expected Ruby class for the extracted value before this post-processor runs.
|
|
38
|
+
VALUE_TYPE = String
|
|
39
39
|
|
|
40
|
-
# Config
|
|
41
|
-
|
|
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 +
|
|
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.
|
|
57
|
+
def self.schema_export = SchemaExport.for_post_processor(name: :template, klass: self)
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
#
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
string = context[:
|
|
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.
|
|
71
|
+
return if context.item_env
|
|
67
72
|
|
|
68
|
-
raise MissingOption, 'The post-processor context is missing `
|
|
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::
|
|
78
|
+
# @param context [Selectors::StepEnv]
|
|
74
79
|
def initialize(value, context)
|
|
75
80
|
super
|
|
76
81
|
|
|
77
|
-
@
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
#
|
|
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::
|
|
40
|
+
# @param context [Selectors::StepEnv] post-processor step context carrying +step_config+
|
|
40
41
|
# @return [Object] transformed selector value
|
|
41
|
-
def self.
|
|
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).
|
|
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 +
|
|
8
|
+
# Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTIONS+;
|
|
9
9
|
# this module is the export adapter only.
|
|
10
|
-
module
|
|
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
|
-
|
|
60
|
-
properties[
|
|
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
|
-
|
|
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
|
|
79
|
-
# @return [Hash{Symbol =>
|
|
80
|
-
def
|
|
81
|
-
|
|
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
|
-
|
|
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
|