html2rss 0.32.4 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +7 -3
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
|
@@ -14,48 +14,47 @@ module Html2rss
|
|
|
14
14
|
array: ['<array>', '</array>']
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
class << self
|
|
18
|
+
##
|
|
19
|
+
# Converts the object to XML format.
|
|
20
|
+
#
|
|
21
|
+
# @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
|
|
22
|
+
# @return [String] representing the object in XML
|
|
23
|
+
def call(object)
|
|
24
|
+
object_to_xml(object).tap do |converted|
|
|
25
|
+
Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
|
|
26
|
+
end
|
|
27
27
|
end
|
|
28
|
-
end
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def object_to_xml(object)
|
|
32
|
+
case object
|
|
33
|
+
when Hash
|
|
34
|
+
hash_to_xml(object)
|
|
35
|
+
when Array
|
|
36
|
+
array_to_xml(object)
|
|
37
|
+
else
|
|
38
|
+
CGI.escapeHTML(object.to_s)
|
|
39
|
+
end
|
|
38
40
|
end
|
|
39
|
-
end
|
|
40
|
-
private_class_method :object_to_xml
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
def hash_to_xml(object)
|
|
43
|
+
prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
|
|
44
|
+
inner_xml = object.each_with_object(+'') do |(key, value), str|
|
|
45
|
+
str << "<#{key}>#{object_to_xml(value)}</#{key}>"
|
|
46
|
+
end
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
private_class_method :hash_to_xml
|
|
48
|
+
"#{prefix}#{inner_xml}#{suffix}"
|
|
49
|
+
end
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
def array_to_xml(object)
|
|
52
|
+
prefix, suffix = OBJECT_TO_XML_TAGS[:array]
|
|
53
|
+
inner_xml = object.each_with_object(+'') { |value, str| str << object_to_xml(value) }
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
"#{prefix}#{inner_xml}#{suffix}"
|
|
56
|
+
end
|
|
57
57
|
end
|
|
58
|
-
private_class_method :array_to_xml
|
|
59
58
|
end
|
|
60
59
|
end
|
|
61
60
|
end
|
|
@@ -101,7 +101,7 @@ module Html2rss
|
|
|
101
101
|
|
|
102
102
|
source = regexp_source(string)
|
|
103
103
|
|
|
104
|
-
expression = Regexp::Parser.parse(source
|
|
104
|
+
expression = Regexp::Parser.parse(source)
|
|
105
105
|
raise ArgumentError, 'pattern contains nested quantifiers' if nested_quantifiers?(expression)
|
|
106
106
|
|
|
107
107
|
expression.to_re
|
|
@@ -161,8 +161,7 @@ module Html2rss
|
|
|
161
161
|
##
|
|
162
162
|
# @return [Regexp]
|
|
163
163
|
def pattern
|
|
164
|
-
|
|
165
|
-
raw.is_a?(String) ? self.class.compiled_pattern(raw) : raw
|
|
164
|
+
self.class.compiled_pattern(@pattern)
|
|
166
165
|
end
|
|
167
166
|
end
|
|
168
167
|
end
|
|
@@ -67,11 +67,10 @@ module Html2rss
|
|
|
67
67
|
def self.validate_args!(_value, context)
|
|
68
68
|
time_zone_value = context.time_zone
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil
|
|
72
|
-
end
|
|
70
|
+
raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.nil?
|
|
73
71
|
|
|
74
72
|
assert_type(time_zone_value, String, :time_zone, context:)
|
|
73
|
+
raise ArgumentError, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.empty?
|
|
75
74
|
end
|
|
76
75
|
|
|
77
76
|
##
|
|
@@ -89,7 +88,7 @@ module Html2rss
|
|
|
89
88
|
private
|
|
90
89
|
|
|
91
90
|
def parse_time(string, time_zone)
|
|
92
|
-
components = Date._parse(string
|
|
91
|
+
components = Date._parse(string)
|
|
93
92
|
if components[:zone] || components.key?(:offset)
|
|
94
93
|
Time.parse(string)
|
|
95
94
|
else
|
|
@@ -104,7 +103,7 @@ module Html2rss
|
|
|
104
103
|
components[:mday],
|
|
105
104
|
components[:hour] || 0,
|
|
106
105
|
components[:min] || 0,
|
|
107
|
-
|
|
106
|
+
components[:sec] || 0
|
|
108
107
|
)
|
|
109
108
|
end
|
|
110
109
|
end
|
|
@@ -8,72 +8,72 @@ module Html2rss
|
|
|
8
8
|
# Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTIONS+;
|
|
9
9
|
# this module is the export adapter only.
|
|
10
10
|
module SchemaExport
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
11
|
+
class << self
|
|
12
|
+
##
|
|
13
|
+
# @param name [Symbol, String] registry key
|
|
14
|
+
# @param klass [Class] post-processor class
|
|
15
|
+
# @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
|
|
16
|
+
def for_post_processor(name:, klass:)
|
|
17
|
+
name = name.to_s
|
|
18
|
+
{
|
|
19
|
+
type: 'object',
|
|
20
|
+
title: name,
|
|
21
|
+
description: klass::DESCRIPTION,
|
|
22
|
+
examples: klass::EXAMPLES,
|
|
23
|
+
properties: post_processor_properties(name, klass),
|
|
24
|
+
required: post_processor_required(klass),
|
|
25
|
+
additionalProperties: true
|
|
26
|
+
}
|
|
27
|
+
end
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
##
|
|
30
|
+
# @param name [Symbol, String] registry key
|
|
31
|
+
# @param klass [Class] extractor class
|
|
32
|
+
# @return [Hash{Symbol => Object}] JSON Schema for one extractor name
|
|
33
|
+
def for_extractor(name:, klass:)
|
|
34
|
+
name = name.to_s
|
|
35
|
+
{
|
|
36
|
+
type: 'string',
|
|
37
|
+
const: name,
|
|
38
|
+
title: name,
|
|
39
|
+
description: klass::DESCRIPTION,
|
|
40
|
+
examples: klass::EXAMPLES
|
|
41
|
+
}
|
|
42
|
+
end
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
##
|
|
45
|
+
# @param name [String] registry key
|
|
46
|
+
# @param klass [Class]
|
|
47
|
+
# @return [Hash{Symbol => Hash}]
|
|
48
|
+
def post_processor_properties(name, klass)
|
|
49
|
+
properties = { name: { type: 'string', const: name } }
|
|
50
|
+
OptionSpec.for(klass).each do |spec|
|
|
51
|
+
properties[spec.name] = property_schema_for(spec.type)
|
|
52
|
+
end
|
|
53
|
+
properties
|
|
53
54
|
end
|
|
54
|
-
properties
|
|
55
|
-
end
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
##
|
|
57
|
+
# @param klass [Class]
|
|
58
|
+
# @return [Array<String>]
|
|
59
|
+
def post_processor_required(klass)
|
|
60
|
+
required = ['name']
|
|
61
|
+
required + OptionSpec.for(klass).select(&:required).map { |spec| spec.name.to_s }
|
|
62
|
+
end
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
##
|
|
65
|
+
# @param ruby_type [Class, Array<Class>]
|
|
66
|
+
# @return [Hash{Symbol => String, Array<String>}]
|
|
67
|
+
def property_schema_for(ruby_type)
|
|
68
|
+
{ type: json_type_for(ruby_type) }
|
|
69
|
+
end
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
##
|
|
72
|
+
# @param ruby_type [Class, Array<Class>]
|
|
73
|
+
# @return [String, Array<String>]
|
|
74
|
+
def json_type_for(ruby_type)
|
|
75
|
+
OptionSpec.json_type_for(ruby_type)
|
|
76
|
+
end
|
|
77
77
|
end
|
|
78
78
|
end
|
|
79
79
|
end
|
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -132,7 +132,7 @@ module Html2rss
|
|
|
132
132
|
|
|
133
133
|
field = @compiled_fields[name]
|
|
134
134
|
return select_compiled(field, scope) if field
|
|
135
|
-
return select_field(name, scope
|
|
135
|
+
return select_field(name, scope:) if name == :url
|
|
136
136
|
|
|
137
137
|
raise InvalidSelectorName, "Selector for '#{name}' is not defined."
|
|
138
138
|
end
|
|
@@ -225,15 +225,11 @@ module Html2rss
|
|
|
225
225
|
value.is_a?(Array) ? value : [value]
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
# Only reached for +:url+ without a configured selector, so the item must be an anchor.
|
|
229
|
+
def select_field(selector_key, scope:)
|
|
230
|
+
return default_item_url(scope.item, scope.base_url) if anchor_element?(scope.item)
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
select_special(selector_key, scope:, config:)
|
|
234
|
-
else
|
|
235
|
-
select_regular(selector_key, scope:, config:)
|
|
236
|
-
end
|
|
232
|
+
raise InvalidSelectorName, "Selector for '#{selector_key}' is not defined."
|
|
237
233
|
end
|
|
238
234
|
|
|
239
235
|
def select_compiled(field, scope)
|
|
@@ -327,6 +323,7 @@ module Html2rss
|
|
|
327
323
|
end
|
|
328
324
|
|
|
329
325
|
def post_process(scope, value, post_process_steps)
|
|
326
|
+
value = step_input(value)
|
|
330
327
|
post_process_steps.each do |step_config|
|
|
331
328
|
value = PostProcessors.call(step_config[:name], value, scope.context_for(step_config:))
|
|
332
329
|
end
|
|
@@ -334,9 +331,15 @@ module Html2rss
|
|
|
334
331
|
value
|
|
335
332
|
end
|
|
336
333
|
|
|
334
|
+
# Post-processors work on text, so a URL value (from the href extractor) enters them as a String.
|
|
335
|
+
def step_input(value)
|
|
336
|
+
value.is_a?(Url) ? value.to_s : value
|
|
337
|
+
end
|
|
338
|
+
|
|
337
339
|
def apply_compiled_post_process(scope, value, steps)
|
|
338
340
|
return value unless value && steps
|
|
339
341
|
|
|
342
|
+
value = step_input(value)
|
|
340
343
|
steps.each do |klass, step_config|
|
|
341
344
|
value = klass.new(value, scope.context_for(step_config:)).call
|
|
342
345
|
end
|
|
@@ -375,15 +378,8 @@ module Html2rss
|
|
|
375
378
|
end
|
|
376
379
|
|
|
377
380
|
def extract_category_text(category)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
Html2rss::Html::Navigator.extract_visible_text(category)
|
|
381
|
-
else
|
|
382
|
-
category&.to_s
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
stripped = text&.strip
|
|
386
|
-
stripped unless stripped.nil? || stripped.empty?
|
|
381
|
+
stripped = category.to_s.strip
|
|
382
|
+
stripped unless stripped.empty?
|
|
387
383
|
end
|
|
388
384
|
|
|
389
385
|
def node_set_with_multiple_elements?(nodes)
|
data/lib/html2rss/sst/node.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module SST
|
|
5
|
-
Node = Data.define(:name, :attrs, :own_text, :children, :tag_path)
|
|
5
|
+
Node = Data.define(:name, :attrs, :own_text, :tails, :children, :tag_path)
|
|
6
6
|
|
|
7
7
|
##
|
|
8
8
|
# Immutable simplified semantic tree node. Predicates and traversal live on
|
|
@@ -11,12 +11,13 @@ module Html2rss
|
|
|
11
11
|
##
|
|
12
12
|
# @param name [Symbol, String]
|
|
13
13
|
# @param attrs [Attrs, nil]
|
|
14
|
-
# @param own_text [String, nil]
|
|
14
|
+
# @param own_text [String, nil] all direct text, in document order
|
|
15
|
+
# @param tails [Array<String>, nil] direct text following each child, parallel to +children+
|
|
15
16
|
# @param children [Array<Node>, nil]
|
|
16
17
|
# @param tag_path [String, nil] index-free ancestry path for list clustering
|
|
17
18
|
# @return [Node]
|
|
18
19
|
# @raise [ArgumentError] on invalid name/attrs/children
|
|
19
|
-
def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil)
|
|
20
|
+
def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil, tails: nil) # rubocop:disable Metrics/ParameterLists
|
|
20
21
|
attrs_value = coerce_attrs(attrs)
|
|
21
22
|
kids = coerce_children(children)
|
|
22
23
|
|
|
@@ -24,6 +25,7 @@ module Html2rss
|
|
|
24
25
|
name: coerce_name(name),
|
|
25
26
|
attrs: attrs_value,
|
|
26
27
|
own_text: own_text.to_s.freeze,
|
|
28
|
+
tails: Array(tails).map { _1.to_s.freeze }.freeze,
|
|
27
29
|
children: kids,
|
|
28
30
|
tag_path: tag_path.to_s.freeze
|
|
29
31
|
)
|
|
@@ -52,6 +54,10 @@ module Html2rss
|
|
|
52
54
|
end
|
|
53
55
|
private_class_method :coerce_children
|
|
54
56
|
|
|
57
|
+
##
|
|
58
|
+
# @return [String] direct text before the first child
|
|
59
|
+
def lead_text = own_text.delete_suffix(tails.join)
|
|
60
|
+
|
|
55
61
|
##
|
|
56
62
|
# @return [Boolean]
|
|
57
63
|
def link?
|
|
@@ -8,9 +8,6 @@ module Html2rss
|
|
|
8
8
|
# Sole Nokogiri consumer on the heuristic auto-source path. Builds an
|
|
9
9
|
# immutable SST::Document with parent/depth/chrome indices.
|
|
10
10
|
class Normalizer # rubocop:disable Metrics/ClassLength
|
|
11
|
-
# Raised when no SST nodes survive normalization for the chosen root.
|
|
12
|
-
class EmptyTree < ArgumentError; end
|
|
13
|
-
|
|
14
11
|
# Hard ceiling for SST node allocations; beyond this we degrade.
|
|
15
12
|
MAX_NODES = 5_000
|
|
16
13
|
|
|
@@ -64,10 +61,10 @@ module Html2rss
|
|
|
64
61
|
##
|
|
65
62
|
# @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
|
|
66
63
|
# HTML string or already-parsed node (String is parsed once here)
|
|
67
|
-
# @return [Document]
|
|
68
|
-
# @raise [
|
|
64
|
+
# @return [Document, nil] +nil+ when no SST nodes survive normalization
|
|
65
|
+
# @raise [InvalidOption] when input is nil or unsupported
|
|
69
66
|
def call(input)
|
|
70
|
-
raise
|
|
67
|
+
raise InvalidOption, 'input is required' if input.nil?
|
|
71
68
|
|
|
72
69
|
new(coerce(input)).call
|
|
73
70
|
end
|
|
@@ -81,7 +78,7 @@ module Html2rss
|
|
|
81
78
|
when String then Nokogiri::HTML(input)
|
|
82
79
|
when Nokogiri::XML::Node then input
|
|
83
80
|
else
|
|
84
|
-
raise
|
|
81
|
+
raise InvalidOption, "expected String or Nokogiri::XML::Node, got #{input.class}"
|
|
85
82
|
end
|
|
86
83
|
end
|
|
87
84
|
end
|
|
@@ -97,11 +94,11 @@ module Html2rss
|
|
|
97
94
|
end
|
|
98
95
|
|
|
99
96
|
##
|
|
100
|
-
# @return [Document]
|
|
97
|
+
# @return [Document, nil] +nil+ when no SST nodes survive normalization
|
|
101
98
|
def call
|
|
102
99
|
root_nk = resolve_root_nk
|
|
103
|
-
root = normalize_element(root_nk,
|
|
104
|
-
|
|
100
|
+
root = normalize_element(root_nk, depth: 0, path: '', chrome: false)
|
|
101
|
+
return unless root
|
|
105
102
|
|
|
106
103
|
index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
|
|
107
104
|
Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
|
|
@@ -150,15 +147,15 @@ module Html2rss
|
|
|
150
147
|
end
|
|
151
148
|
|
|
152
149
|
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
153
|
-
def normalize_element(nk_node,
|
|
150
|
+
def normalize_element(nk_node, depth:, path:, chrome:)
|
|
154
151
|
return unless nk_node.element?
|
|
155
152
|
|
|
156
|
-
tag =
|
|
153
|
+
tag = Html::Probe.tag(nk_node)
|
|
157
154
|
return if STRIPPED_TAGS.include?(tag)
|
|
158
155
|
|
|
159
156
|
if @node_count >= MAX_NODES && !@degraded
|
|
160
157
|
@degraded = true
|
|
161
|
-
|
|
158
|
+
Log.warn({
|
|
162
159
|
event_name: 'sst.normalizer.max_nodes',
|
|
163
160
|
max_nodes: MAX_NODES,
|
|
164
161
|
message: "sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only"
|
|
@@ -168,21 +165,15 @@ module Html2rss
|
|
|
168
165
|
return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
|
|
169
166
|
|
|
170
167
|
@node_count += 1
|
|
171
|
-
tag_path =
|
|
168
|
+
tag_path = "#{path}/#{tag}"
|
|
172
169
|
attrs = extract_attrs(nk_node)
|
|
173
|
-
own_text = direct_text(nk_node)
|
|
174
170
|
chrome_here = chrome || IGNORED_CONTAINER_TAGS.include?(tag)
|
|
175
171
|
|
|
176
|
-
children = nk_node
|
|
177
|
-
normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
|
|
178
|
-
end.freeze
|
|
172
|
+
children, lead, tails = normalize_children(nk_node, depth:, path: tag_path, chrome: chrome_here)
|
|
179
173
|
|
|
180
|
-
node = Node.build(name: tag, attrs:, own_text
|
|
181
|
-
@parents[node] = parent == :pending ? nil : parent
|
|
174
|
+
node = Node.build(name: tag, attrs:, own_text: [lead, *tails].join, children:, tag_path:, tails:)
|
|
182
175
|
@depths[node] = depth
|
|
183
176
|
@ignored_chrome[node] = chrome_here
|
|
184
|
-
|
|
185
|
-
# Fix parent pointers now that node exists.
|
|
186
177
|
children.each { |child| @parents[child] = node }
|
|
187
178
|
|
|
188
179
|
node
|
|
@@ -213,8 +204,18 @@ module Html2rss
|
|
|
213
204
|
end
|
|
214
205
|
end
|
|
215
206
|
|
|
216
|
-
|
|
217
|
-
|
|
207
|
+
# Walks child nodes once, keeping direct text in the slot of the preceding kept child
|
|
208
|
+
# (slot 0 is the lead text) so text order survives the element-only tree.
|
|
209
|
+
def normalize_children(nk_node, depth:, path:, chrome:)
|
|
210
|
+
children = []
|
|
211
|
+
slots = [+'']
|
|
212
|
+
nk_node.children.each do |child|
|
|
213
|
+
next slots.last << child.text if child.text?
|
|
214
|
+
|
|
215
|
+
node = normalize_element(child, depth: depth + 1, path:, chrome:)
|
|
216
|
+
(children << node) && (slots << +'') if node
|
|
217
|
+
end
|
|
218
|
+
[children, slots.first, slots.drop(1)]
|
|
218
219
|
end
|
|
219
220
|
end
|
|
220
221
|
end
|
data/lib/html2rss/sst/text.rb
CHANGED
|
@@ -5,77 +5,73 @@ module Html2rss
|
|
|
5
5
|
##
|
|
6
6
|
# Visible-text extraction for SST nodes (port of Navigator::TextExtractor).
|
|
7
7
|
module Text
|
|
8
|
-
|
|
8
|
+
class << self
|
|
9
|
+
##
|
|
10
|
+
# @param node [Node]
|
|
11
|
+
# @param separator [String]
|
|
12
|
+
# @param exclude [Array<Node>, nil]
|
|
13
|
+
# @return [String, nil]
|
|
14
|
+
def extract(node, separator: ' ', exclude: nil)
|
|
15
|
+
raise ArgumentError, 'node is required' unless node
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# @param separator [String]
|
|
13
|
-
# @param exclude [Array<Node>, nil]
|
|
14
|
-
# @return [String, nil]
|
|
15
|
-
def extract(node, separator: ' ', exclude: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
16
|
-
raise ArgumentError, 'node is required' unless node
|
|
17
|
-
|
|
18
|
-
excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
|
|
19
|
-
if node.children.empty?
|
|
20
|
-
text = node.own_text.to_s.gsub(/\s+/, ' ').strip
|
|
21
|
-
return text.empty? ? nil : text
|
|
17
|
+
excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
|
|
18
|
+
join_fragments(fragments(node, separator, excluded), separator)
|
|
22
19
|
end
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
own = node.own_text.to_s.gsub(/\s+/, ' ').strip
|
|
26
|
-
parts = [own, *parts] unless own.empty?
|
|
27
|
-
return if parts.empty?
|
|
28
|
-
|
|
29
|
-
parts.join.squeeze(' ').strip
|
|
30
|
-
end
|
|
21
|
+
private
|
|
31
22
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
# Ordered [text, block?] pairs: lead text, then each child followed by the text after it.
|
|
24
|
+
def fragments(node, separator, excluded)
|
|
25
|
+
pairs = [[squish(node.lead_text), false]]
|
|
26
|
+
node.children.zip(node.tails).each do |child, tail|
|
|
27
|
+
pairs << child_text_and_block(child, separator, excluded) if shown?(child, excluded)
|
|
28
|
+
pairs << [squish(tail), false]
|
|
29
|
+
end
|
|
30
|
+
pairs
|
|
31
|
+
end
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
def join_fragments(pairs, separator)
|
|
34
|
+
parts = []
|
|
35
|
+
last_block = false
|
|
36
|
+
pairs.each do |text, block|
|
|
37
|
+
next if text.empty?
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
append_separator!(parts, separator, block, last_block)
|
|
40
|
+
parts << text
|
|
41
|
+
last_block = block
|
|
42
|
+
end
|
|
43
|
+
parts.join unless parts.empty?
|
|
44
44
|
end
|
|
45
|
-
end
|
|
46
|
-
module_function :collect_parts
|
|
47
|
-
private_class_method :collect_parts
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
text = if child.children.empty?
|
|
51
|
-
child.own_text.gsub(/\s+/, ' ').strip
|
|
52
|
-
else
|
|
53
|
-
extract(child, separator:, exclude: excluded&.keys).to_s.strip
|
|
54
|
-
end
|
|
55
|
-
text = "- #{text}" if child.name == :li && !text.empty?
|
|
56
|
-
[text, Tags::BLOCK_NAMES.include?(child.name)]
|
|
57
|
-
end
|
|
58
|
-
module_function :child_text_and_block
|
|
59
|
-
private_class_method :child_text_and_block
|
|
46
|
+
def shown?(child, excluded) = !excluded&.[](child) && visible_child?(child)
|
|
60
47
|
|
|
61
|
-
|
|
62
|
-
return if parts.empty?
|
|
48
|
+
def squish(text) = text.to_s.gsub(/\s+/, ' ').strip
|
|
63
49
|
|
|
64
|
-
|
|
65
|
-
|
|
50
|
+
def child_text_and_block(child, separator, excluded)
|
|
51
|
+
text = if child.children.empty?
|
|
52
|
+
squish(child.own_text)
|
|
66
53
|
else
|
|
67
|
-
|
|
54
|
+
extract(child, separator:, exclude: excluded&.keys).to_s.strip
|
|
68
55
|
end
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
56
|
+
text = "- #{text}" if child.name == :li && !text.empty?
|
|
57
|
+
[text, Tags::BLOCK_NAMES.include?(child.name)]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def append_separator!(parts, separator, is_block, last_was_block)
|
|
61
|
+
return if parts.empty?
|
|
72
62
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
63
|
+
parts << if is_block || last_was_block
|
|
64
|
+
(separator == ' ' ? "\n" : separator)
|
|
65
|
+
else
|
|
66
|
+
' '
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def visible_child?(node)
|
|
71
|
+
!Tags::INVISIBLE_NAMES.include?(node.name) &&
|
|
72
|
+
!(node.name == :a && node.attrs.href&.start_with?('#'))
|
|
73
|
+
end
|
|
76
74
|
end
|
|
77
|
-
module_function :visible_child?
|
|
78
|
-
private_class_method :visible_child?
|
|
79
75
|
end
|
|
80
76
|
end
|
|
81
77
|
end
|