html2rss 0.24.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
|
@@ -38,6 +38,15 @@ module Html2rss
|
|
|
38
38
|
# Would return:
|
|
39
39
|
# '<p>Lorem <b>ipsum</b> dolor ...</p>'
|
|
40
40
|
class SanitizeHtml < Base
|
|
41
|
+
# JSON Schema description exported via +schema_doc+.
|
|
42
|
+
DESCRIPTION = 'Sanitize HTML (sanitize gem RELAXED plus html2rss defaults: absolute URLs, ' \
|
|
43
|
+
'safe link/img attributes, wrap lone images in anchors).'
|
|
44
|
+
|
|
45
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
46
|
+
EXAMPLES = [
|
|
47
|
+
{ 'name' => 'sanitize_html' }
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
41
50
|
# @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
|
|
42
51
|
TAG_ATTRIBUTES = {
|
|
43
52
|
'a' => {
|
|
@@ -81,6 +90,10 @@ module Html2rss
|
|
|
81
90
|
'preload' => 'none'
|
|
82
91
|
}
|
|
83
92
|
}.freeze
|
|
93
|
+
|
|
94
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
95
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :sanitize_html, klass: self)
|
|
96
|
+
|
|
84
97
|
# @param value [String] extracted selector value
|
|
85
98
|
# @param context [Selectors::Context] post-processor context
|
|
86
99
|
# @return [void]
|
|
@@ -92,12 +105,19 @@ module Html2rss
|
|
|
92
105
|
# @param html [String]
|
|
93
106
|
# @param url [String, Html2rss::Url]
|
|
94
107
|
# @return [String, nil]
|
|
108
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
95
109
|
def self.get(html, url)
|
|
96
110
|
return nil if String(html).empty?
|
|
97
111
|
|
|
112
|
+
@fragment_cache ||= {}
|
|
113
|
+
key = [html, url.to_s]
|
|
114
|
+
return @fragment_cache[key] if @fragment_cache.key?(key)
|
|
115
|
+
|
|
116
|
+
@fragment_cache.clear if @fragment_cache.size > 256
|
|
98
117
|
context = Selectors::Context.new(config: { channel: { url: } }, options: {})
|
|
99
|
-
new(html, context).get
|
|
118
|
+
@fragment_cache[key] = new(html, context).get
|
|
100
119
|
end
|
|
120
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
101
121
|
|
|
102
122
|
##
|
|
103
123
|
# @param channel_url [String, Html2rss::Url]
|
|
@@ -117,6 +137,8 @@ module Html2rss
|
|
|
117
137
|
->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
|
|
118
138
|
]
|
|
119
139
|
)
|
|
140
|
+
config[:elements].delete('style')
|
|
141
|
+
config.delete(:css)
|
|
120
142
|
config[:elements].push('audio', 'video', 'source')
|
|
121
143
|
config.freeze
|
|
122
144
|
end
|
|
@@ -30,6 +30,27 @@ module Html2rss
|
|
|
30
30
|
# Would return:
|
|
31
31
|
# 'bar'
|
|
32
32
|
class Substring < Base
|
|
33
|
+
# Required config field types (validator introspection via +Options+).
|
|
34
|
+
OPTION_TYPES = { start: Integer }.freeze
|
|
35
|
+
|
|
36
|
+
# Optional config field types (validated when the key is present and non-nil).
|
|
37
|
+
OPTIONAL_OPTION_TYPES = { end: Integer }.freeze
|
|
38
|
+
|
|
39
|
+
# Config fields required by this post-processor (validator / schema introspection).
|
|
40
|
+
Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
# JSON Schema description exported via +schema_doc+.
|
|
43
|
+
DESCRIPTION = 'Return a slice of the extracted string using Integer `start` and optional `end` ' \
|
|
44
|
+
'(Ruby String#[] range semantics; end may be omitted).'
|
|
45
|
+
|
|
46
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
47
|
+
EXAMPLES = [
|
|
48
|
+
{ 'name' => 'substring', 'start' => 4, 'end' => 6 }
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
52
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :substring, klass: self)
|
|
53
|
+
|
|
33
54
|
# @param value [String] extracted selector value
|
|
34
55
|
# @param context [Selectors::Context] post-processor context
|
|
35
56
|
# @return [void]
|
|
@@ -34,6 +34,26 @@ module Html2rss
|
|
|
34
34
|
# Would return:
|
|
35
35
|
# 'Product (23,42€)'
|
|
36
36
|
class Template < Base
|
|
37
|
+
# Required config field types (validator introspection via +Options+).
|
|
38
|
+
OPTION_TYPES = { string: String }.freeze
|
|
39
|
+
|
|
40
|
+
# Config fields required by this post-processor (validator / schema introspection).
|
|
41
|
+
Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
# JSON Schema description exported via +schema_doc+.
|
|
44
|
+
# rubocop:disable Style/FormatStringToken -- documents Kernel#format `%{key}` placeholders
|
|
45
|
+
DESCRIPTION = 'Format a string with Kernel#format-style placeholders (`%{key}` / `%<key>s`). ' \
|
|
46
|
+
'`%{self}` is the current selector value; other keys resolve sibling selectors.'
|
|
47
|
+
|
|
48
|
+
# Example post-process objects for JSON Schema +examples+.
|
|
49
|
+
EXAMPLES = [
|
|
50
|
+
{ 'name' => 'template', 'string' => '`%{self}` (`%{price}`)' }
|
|
51
|
+
].freeze
|
|
52
|
+
# rubocop:enable Style/FormatStringToken
|
|
53
|
+
|
|
54
|
+
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
55
|
+
def self.schema_doc = SchemaDoc.for_post_processor(name: :template, klass: self)
|
|
56
|
+
|
|
37
57
|
# @param value [String] extracted selector value
|
|
38
58
|
# @param context [Selectors::Context] post-processor context
|
|
39
59
|
# @return [void]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class Selectors
|
|
5
|
+
##
|
|
6
|
+
# Builds JSON Schema fragments from extractor / post-processor class constants.
|
|
7
|
+
#
|
|
8
|
+
# Each registry class owns +DESCRIPTION+, +EXAMPLES+, and optional +OPTION_TYPES+;
|
|
9
|
+
# this module is the export adapter only.
|
|
10
|
+
module SchemaDoc
|
|
11
|
+
# Maps Ruby option types to JSON Schema +type+ strings.
|
|
12
|
+
RUBY_TO_JSON_TYPE = {
|
|
13
|
+
String => 'string',
|
|
14
|
+
Integer => 'integer',
|
|
15
|
+
Hash => 'object',
|
|
16
|
+
Array => 'array'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @param name [Symbol, String] registry key
|
|
23
|
+
# @param klass [Class] post-processor class
|
|
24
|
+
# @return [Hash{Symbol => Object}] JSON Schema object for one post-processor
|
|
25
|
+
def for_post_processor(name:, klass:)
|
|
26
|
+
name = name.to_s
|
|
27
|
+
{
|
|
28
|
+
type: 'object',
|
|
29
|
+
title: name,
|
|
30
|
+
description: klass::DESCRIPTION,
|
|
31
|
+
examples: klass::EXAMPLES,
|
|
32
|
+
properties: post_processor_properties(name, klass),
|
|
33
|
+
required: post_processor_required(klass),
|
|
34
|
+
additionalProperties: true
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param name [Symbol, String] registry key
|
|
40
|
+
# @param klass [Class] extractor class
|
|
41
|
+
# @return [Hash{Symbol => Object}] JSON Schema for one extractor name
|
|
42
|
+
def for_extractor(name:, klass:)
|
|
43
|
+
name = name.to_s
|
|
44
|
+
{
|
|
45
|
+
type: 'string',
|
|
46
|
+
const: name,
|
|
47
|
+
title: name,
|
|
48
|
+
description: klass::DESCRIPTION,
|
|
49
|
+
examples: klass::EXAMPLES
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# @param name [String] registry key
|
|
55
|
+
# @param klass [Class]
|
|
56
|
+
# @return [Hash{Symbol => Hash}]
|
|
57
|
+
def post_processor_properties(name, klass)
|
|
58
|
+
properties = { name: { type: 'string', const: name } }
|
|
59
|
+
option_types_for(klass).each do |field, ruby_type|
|
|
60
|
+
properties[field] = { type: json_type_for(ruby_type) }
|
|
61
|
+
end
|
|
62
|
+
properties
|
|
63
|
+
end
|
|
64
|
+
module_function :post_processor_properties
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# @param klass [Class]
|
|
68
|
+
# @return [Array<String>]
|
|
69
|
+
def post_processor_required(klass)
|
|
70
|
+
required = ['name']
|
|
71
|
+
return required unless klass.const_defined?(:OPTION_TYPES)
|
|
72
|
+
|
|
73
|
+
required + klass::OPTION_TYPES.keys.map(&:to_s)
|
|
74
|
+
end
|
|
75
|
+
module_function :post_processor_required
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# @param klass [Class]
|
|
79
|
+
# @return [Hash{Symbol => Class}]
|
|
80
|
+
def option_types_for(klass)
|
|
81
|
+
types = {}
|
|
82
|
+
types.merge!(klass::OPTION_TYPES) if klass.const_defined?(:OPTION_TYPES)
|
|
83
|
+
types.merge!(klass::OPTIONAL_OPTION_TYPES) if klass.const_defined?(:OPTIONAL_OPTION_TYPES)
|
|
84
|
+
types
|
|
85
|
+
end
|
|
86
|
+
module_function :option_types_for
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# @param ruby_type [Class]
|
|
90
|
+
# @return [String]
|
|
91
|
+
def json_type_for(ruby_type)
|
|
92
|
+
RUBY_TO_JSON_TYPE.fetch(ruby_type) do
|
|
93
|
+
raise ArgumentError, "unsupported OPTION_TYPES mapping for #{ruby_type}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
module_function :json_type_for
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -48,8 +48,6 @@ module Html2rss
|
|
|
48
48
|
@url = response.url
|
|
49
49
|
@selectors = selectors
|
|
50
50
|
@time_zone = time_zone
|
|
51
|
-
|
|
52
|
-
prepare_selectors!
|
|
53
51
|
@rss_item_attributes = @selectors.keys.select { |key| SELECTABLE_SELECTOR_KEYS.include?(key) }
|
|
54
52
|
end
|
|
55
53
|
|
|
@@ -180,24 +178,6 @@ module Html2rss
|
|
|
180
178
|
)
|
|
181
179
|
end
|
|
182
180
|
|
|
183
|
-
def prepare_selectors!
|
|
184
|
-
validate_url_and_link_exclusivity!
|
|
185
|
-
fix_url_and_link!
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def validate_url_and_link_exclusivity!
|
|
189
|
-
return unless @selectors.key?(:url) && @selectors.key?(:link)
|
|
190
|
-
|
|
191
|
-
raise InvalidSelectorName, 'You must either use "url" or "link" your selectors. Using both is not supported.'
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def fix_url_and_link!
|
|
195
|
-
return if @selectors[:url] || !@selectors.key?(:link)
|
|
196
|
-
|
|
197
|
-
@selectors = @selectors.dup
|
|
198
|
-
@selectors[:url] = @selectors[:link]
|
|
199
|
-
end
|
|
200
|
-
|
|
201
181
|
def parsed_body
|
|
202
182
|
parsed_body_for(response)
|
|
203
183
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module SST
|
|
5
|
+
##
|
|
6
|
+
# Shared empty defaults for {Attrs} (kept outside Data.define for constant visibility).
|
|
7
|
+
module AttrDefaults
|
|
8
|
+
# Shared empty class_names array for Attrs defaults.
|
|
9
|
+
EMPTY_CLASS_NAMES = [].freeze
|
|
10
|
+
# Shared empty raw attribute hash for Attrs defaults.
|
|
11
|
+
EMPTY_RAW = {}.freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# Typed HTML attributes for an SST node. Absent fields are +nil+.
|
|
16
|
+
Attrs = Data.define(
|
|
17
|
+
:href, :src, :id, :class_names, :datetime, :itemprop, :style, :srcset, :type, :raw
|
|
18
|
+
) do
|
|
19
|
+
class << self
|
|
20
|
+
##
|
|
21
|
+
# @param href [String, nil]
|
|
22
|
+
# @param src [String, nil]
|
|
23
|
+
# @param id [String, nil]
|
|
24
|
+
# @param class_names [Array<String>, nil]
|
|
25
|
+
# @param datetime [String, nil]
|
|
26
|
+
# @param itemprop [String, nil]
|
|
27
|
+
# @param style [String, nil]
|
|
28
|
+
# @param srcset [String, nil]
|
|
29
|
+
# @param type [String, nil]
|
|
30
|
+
# @param raw [Hash{String => String}, nil]
|
|
31
|
+
# @return [Attrs]
|
|
32
|
+
# rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
33
|
+
def build(href: nil, src: nil, id: nil, class_names: nil, datetime: nil, itemprop: nil,
|
|
34
|
+
style: nil, srcset: nil, type: nil, raw: nil)
|
|
35
|
+
new(
|
|
36
|
+
href: blank_to_nil(href),
|
|
37
|
+
src: blank_to_nil(src),
|
|
38
|
+
id: blank_to_nil(id),
|
|
39
|
+
class_names: normalize_class_names(class_names),
|
|
40
|
+
datetime: blank_to_nil(datetime),
|
|
41
|
+
itemprop: blank_to_nil(itemprop),
|
|
42
|
+
style: blank_to_nil(style),
|
|
43
|
+
srcset: blank_to_nil(srcset),
|
|
44
|
+
type: blank_to_nil(type),
|
|
45
|
+
raw: normalize_raw(raw)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
# rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# @return [Attrs]
|
|
52
|
+
def empty = EMPTY_ATTRS
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def blank_to_nil(value)
|
|
57
|
+
str = value&.to_s
|
|
58
|
+
return if str.nil? || str.strip.empty?
|
|
59
|
+
|
|
60
|
+
str.strip.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_class_names(class_names)
|
|
64
|
+
case class_names
|
|
65
|
+
when nil then AttrDefaults::EMPTY_CLASS_NAMES
|
|
66
|
+
when Array then class_names.map { |token| token.to_s.freeze }.freeze
|
|
67
|
+
else raise ArgumentError, "class_names must be an Array, got #{class_names.class}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_raw(raw)
|
|
72
|
+
case raw
|
|
73
|
+
when nil then AttrDefaults::EMPTY_RAW
|
|
74
|
+
when Hash then raw.transform_values { |v| v.to_s.freeze }.freeze
|
|
75
|
+
else raise ArgumentError, "raw must be a Hash, got #{raw.class}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# @return [String]
|
|
82
|
+
def class_attr = class_names.join(' ')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Shared empty Attrs instance for nodes without attributes.
|
|
86
|
+
EMPTY_ATTRS = Attrs.new(
|
|
87
|
+
href: nil, src: nil, id: nil, class_names: AttrDefaults::EMPTY_CLASS_NAMES, datetime: nil,
|
|
88
|
+
itemprop: nil, style: nil, srcset: nil, type: nil, raw: AttrDefaults::EMPTY_RAW
|
|
89
|
+
).freeze
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module SST
|
|
5
|
+
##
|
|
6
|
+
# Immutable SST document: root node plus relationship index.
|
|
7
|
+
Document = Data.define(:root, :index, :degraded, :node_count) do
|
|
8
|
+
##
|
|
9
|
+
# @param root [Node]
|
|
10
|
+
# @param index [Index]
|
|
11
|
+
# @param degraded [Boolean] true when MAX_NODES forced semantic-tag-only mode
|
|
12
|
+
# @param node_count [Integer]
|
|
13
|
+
# @return [Document]
|
|
14
|
+
# @raise [ArgumentError] when root/index missing
|
|
15
|
+
def self.build(root:, index:, degraded: false, node_count: 0)
|
|
16
|
+
raise ArgumentError, 'root is required' unless root.is_a?(Node)
|
|
17
|
+
raise ArgumentError, 'index is required' unless index.is_a?(Index)
|
|
18
|
+
|
|
19
|
+
new(root:, index:, degraded: !!degraded, node_count: Integer(node_count))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module SST
|
|
5
|
+
##
|
|
6
|
+
# Parent/depth indices for an SST tree. Built once by the Normalizer so
|
|
7
|
+
# Scoring/Segmenter never need Nokogiri ancestor walks.
|
|
8
|
+
class Index
|
|
9
|
+
# Weak node→index bindings so +Node#visible_text+ can memoize without call-site churn.
|
|
10
|
+
NODE_BINDINGS = ObjectSpace::WeakMap.new
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @param node [Node]
|
|
14
|
+
# @return [Index, nil]
|
|
15
|
+
def self.for_node(node) = NODE_BINDINGS[node]
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @param root [Node]
|
|
19
|
+
# @param parents [Hash{Node => Node, nil}]
|
|
20
|
+
# @param depths [Hash{Node => Integer}]
|
|
21
|
+
# @param ignored_chrome [Hash{Node => Boolean}]
|
|
22
|
+
def initialize(root:, parents:, depths:, ignored_chrome:)
|
|
23
|
+
@root = root
|
|
24
|
+
@parents = parents
|
|
25
|
+
@depths = depths
|
|
26
|
+
@ignored_chrome = ignored_chrome
|
|
27
|
+
@visible_text = {}.compare_by_identity
|
|
28
|
+
@word_count = {}.compare_by_identity
|
|
29
|
+
bind_tree(root)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :root
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# @param node [Node]
|
|
36
|
+
# @return [Node, nil]
|
|
37
|
+
def parent_of(node) = @parents[node]
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @param node [Node]
|
|
41
|
+
# @return [Integer]
|
|
42
|
+
def depth_of(node) = @depths.fetch(node, 0)
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# @param node [Node]
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def ignored_chrome?(node) = @ignored_chrome.fetch(node, false)
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# Memoized default visible text (separator space, no excludes).
|
|
51
|
+
#
|
|
52
|
+
# @param node [Node]
|
|
53
|
+
# @return [String, nil]
|
|
54
|
+
def memo_visible_text(node)
|
|
55
|
+
return @visible_text[node] if @visible_text.key?(node)
|
|
56
|
+
|
|
57
|
+
@visible_text[node] = Text.extract(node)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
##
|
|
61
|
+
# @param node [Node]
|
|
62
|
+
# @return [Integer]
|
|
63
|
+
def memo_word_count(node)
|
|
64
|
+
return @word_count[node] if @word_count.key?(node)
|
|
65
|
+
|
|
66
|
+
@word_count[node] = memo_visible_text(node).to_s.scan(/\p{Alnum}+/).size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# @param child [Node]
|
|
71
|
+
# @param ancestor [Node]
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def descendant_of?(child, ancestor)
|
|
74
|
+
curr = parent_of(child)
|
|
75
|
+
while curr
|
|
76
|
+
return true if curr.equal?(ancestor)
|
|
77
|
+
|
|
78
|
+
curr = parent_of(curr)
|
|
79
|
+
end
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# @param node [Node]
|
|
85
|
+
# @param condition [#call]
|
|
86
|
+
# @return [Node, nil]
|
|
87
|
+
def parent_until(node, condition)
|
|
88
|
+
curr = node
|
|
89
|
+
while curr && curr.name != :html
|
|
90
|
+
return curr if condition.call(curr)
|
|
91
|
+
|
|
92
|
+
curr = parent_of(curr)
|
|
93
|
+
end
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
##
|
|
98
|
+
# @yieldparam node [Node]
|
|
99
|
+
# @return [Enumerator]
|
|
100
|
+
def each_node(&)
|
|
101
|
+
root.each_node(&)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def bind_tree(node)
|
|
107
|
+
NODE_BINDINGS[node] = self
|
|
108
|
+
node.children.each { bind_tree(_1) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module SST
|
|
5
|
+
##
|
|
6
|
+
# Immutable simplified semantic tree node. Predicates and traversal live on
|
|
7
|
+
# the type so Scoring/Segmenter never touch Nokogiri.
|
|
8
|
+
Node = Data.define(:name, :attrs, :own_text, :children, :tag_path) do
|
|
9
|
+
##
|
|
10
|
+
# @param name [Symbol, String]
|
|
11
|
+
# @param attrs [Attrs, nil]
|
|
12
|
+
# @param own_text [String, nil]
|
|
13
|
+
# @param children [Array<Node>, nil]
|
|
14
|
+
# @param tag_path [String, nil] index-free ancestry path for list clustering
|
|
15
|
+
# @return [Node]
|
|
16
|
+
# @raise [ArgumentError] on invalid name/attrs/children
|
|
17
|
+
def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil)
|
|
18
|
+
attrs_value = coerce_attrs(attrs)
|
|
19
|
+
kids = coerce_children(children)
|
|
20
|
+
|
|
21
|
+
new(
|
|
22
|
+
name: coerce_name(name),
|
|
23
|
+
attrs: attrs_value,
|
|
24
|
+
own_text: own_text.to_s.freeze,
|
|
25
|
+
children: kids,
|
|
26
|
+
tag_path: tag_path.to_s.freeze
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.coerce_name(name)
|
|
31
|
+
raise ArgumentError, 'name is required' if name.nil? || name.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
name.to_sym
|
|
34
|
+
end
|
|
35
|
+
private_class_method :coerce_name
|
|
36
|
+
|
|
37
|
+
def self.coerce_attrs(attrs)
|
|
38
|
+
attrs_value = attrs.nil? ? Attrs.empty : attrs
|
|
39
|
+
raise ArgumentError, 'attrs must be SST::Attrs' unless attrs_value.is_a?(Attrs)
|
|
40
|
+
|
|
41
|
+
attrs_value
|
|
42
|
+
end
|
|
43
|
+
private_class_method :coerce_attrs
|
|
44
|
+
|
|
45
|
+
def self.coerce_children(children)
|
|
46
|
+
kids = Array(children).freeze
|
|
47
|
+
raise ArgumentError, 'children must be SST::Node instances' unless kids.all?(Node)
|
|
48
|
+
|
|
49
|
+
kids
|
|
50
|
+
end
|
|
51
|
+
private_class_method :coerce_children
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def link?
|
|
56
|
+
name == :a && eligible_href?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def image? = name == :img
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def heading? = Tags::HEADING_NAMES.include?(name)
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def utility_landmark? = Tags::UTILITY_LANDMARK_NAMES.include?(name)
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def ignored_container_name? = Tags::IGNORED_CONTAINER_NAMES.include?(name)
|
|
74
|
+
|
|
75
|
+
##
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def eligible_href?
|
|
78
|
+
href = attrs.href
|
|
79
|
+
return false if href.nil? || href.empty?
|
|
80
|
+
|
|
81
|
+
Tags::SKIP_HREF_PREFIXES.none? { |prefix| href.start_with?(prefix) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# Depth-first enumeration of self and descendants.
|
|
86
|
+
#
|
|
87
|
+
# @yieldparam node [Node]
|
|
88
|
+
# @return [Enumerator, Array<Node>]
|
|
89
|
+
def each_node(&block)
|
|
90
|
+
return enum_for(:each_node) unless block
|
|
91
|
+
|
|
92
|
+
yield self
|
|
93
|
+
children.each { |child| child.each_node(&block) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# @return [Array<Node>] all descendants excluding self
|
|
98
|
+
def descendants
|
|
99
|
+
children.flat_map { |child| [child, *child.descendants] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
##
|
|
103
|
+
# @return [Array<Node>]
|
|
104
|
+
def find_all(&predicate)
|
|
105
|
+
each_node.select(&predicate)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
##
|
|
109
|
+
# @return [Node, nil]
|
|
110
|
+
def find(&predicate)
|
|
111
|
+
each_node.find(&predicate)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
##
|
|
115
|
+
# Visible text with block newlines, excluding optional subtrees.
|
|
116
|
+
#
|
|
117
|
+
# @param separator [String]
|
|
118
|
+
# @param exclude [Array<Node>, nil]
|
|
119
|
+
# @return [String, nil]
|
|
120
|
+
def visible_text(separator: ' ', exclude: nil)
|
|
121
|
+
if exclude.nil? && separator == ' ' && (index = Index.for_node(self))
|
|
122
|
+
return index.memo_visible_text(self)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
Text.extract(self, separator:, exclude:)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
##
|
|
129
|
+
# @return [Integer] alphanumeric word count of visible text
|
|
130
|
+
def word_count
|
|
131
|
+
if (index = Index.for_node(self))
|
|
132
|
+
return index.memo_word_count(self)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
visible_text.to_s.scan(/\p{Alnum}+/).size
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
##
|
|
139
|
+
# @return [Float] words per descendant link
|
|
140
|
+
def text_density
|
|
141
|
+
words = word_count
|
|
142
|
+
links = descendants.count(&:link?)
|
|
143
|
+
links.zero? ? words.to_f : words.to_f / links
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|