html2rss 0.24.0 → 0.25.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/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 +0 -379
- 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.rb +81 -44
- 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 +101 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +66 -0
- data/lib/html2rss/auto_source.rb +74 -21
- data/lib/html2rss/cli.rb +8 -13
- data/lib/html2rss/config/auto_source_contract.rb +2 -0
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/validator.rb +15 -8
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline.rb +4 -0
- data/lib/html2rss/feed_result.rb +1 -1
- 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 +279 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +79 -0
- data/lib/html2rss/link_destination/path_classifier.rb +205 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/scoring/anchor_score.rb +34 -0
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +83 -0
- data/lib/html2rss/scoring/engine.rb +101 -0
- data/lib/html2rss/scoring/link_resolver.rb +72 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +45 -0
- data/lib/html2rss/scoring/score.rb +30 -0
- data/lib/html2rss/scoring.rb +33 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
- 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/version.rb +1 -1
- data/lib/html2rss.rb +26 -26
- data/schema/html2rss-config.schema.json +20 -0
- metadata +42 -18
- 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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Resolves href strings into memoized LinkDestination::DestinationFacts for one page base URL.
|
|
7
|
+
class LinkResolver
|
|
8
|
+
# Captures the href portion before a fragment for memoization keys.
|
|
9
|
+
HREF_BASE_PATTERN = /\A([^#]*)/
|
|
10
|
+
|
|
11
|
+
# @param base_url [String, Html2rss::Url]
|
|
12
|
+
def initialize(base_url)
|
|
13
|
+
@base_url = base_url
|
|
14
|
+
@by_href = {}
|
|
15
|
+
@by_node = {}.compare_by_identity
|
|
16
|
+
@text_classifier = LinkDestination::TextClassifier.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [LinkDestination::TextClassifier]
|
|
20
|
+
attr_reader :text_classifier
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @param node_or_href [SST::Node, String, #to_s]
|
|
24
|
+
# @return [LinkDestination::DestinationFacts, nil]
|
|
25
|
+
def destination_facts(node_or_href)
|
|
26
|
+
return @by_node[node_or_href] if node_or_href.is_a?(SST::Node) && @by_node.key?(node_or_href)
|
|
27
|
+
|
|
28
|
+
href = extract_href(node_or_href)
|
|
29
|
+
return unless href
|
|
30
|
+
|
|
31
|
+
facts = (@by_href[href] ||= build_facts(href))
|
|
32
|
+
@by_node[node_or_href] = facts if node_or_href.is_a?(SST::Node)
|
|
33
|
+
facts
|
|
34
|
+
rescue ArgumentError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param text [String, #to_s]
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def utility_text?(text) = @text_classifier.utility?(text)
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @param text [String, #to_s]
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def utility_prefix_text?(text) = @text_classifier.utility_prefix?(text)
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# @param text [String, #to_s]
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def recommended_text?(text) = @text_classifier.recommended?(text)
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def extract_href(node_or_href)
|
|
56
|
+
raw = case node_or_href
|
|
57
|
+
when SST::Node then node_or_href.attrs.href
|
|
58
|
+
else node_or_href
|
|
59
|
+
end
|
|
60
|
+
return unless raw
|
|
61
|
+
|
|
62
|
+
base = raw.to_s[HREF_BASE_PATTERN, 1].to_s.strip
|
|
63
|
+
base unless base.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_facts(href)
|
|
67
|
+
url = Html2rss::Url.from_relative(href, @base_url)
|
|
68
|
+
LinkDestination::DestinationFacts.build(url)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Typed container signals produced by {ContainerAssessor} for {Engine} rules.
|
|
7
|
+
Observation = Data.define(
|
|
8
|
+
:title_word_count,
|
|
9
|
+
:path_length,
|
|
10
|
+
:content_path,
|
|
11
|
+
:publish_marker,
|
|
12
|
+
:descriptive_context,
|
|
13
|
+
:article_container,
|
|
14
|
+
:content_tokens,
|
|
15
|
+
:junk_tokens,
|
|
16
|
+
:utility_prefix_title,
|
|
17
|
+
:recommended_title,
|
|
18
|
+
:utility_path,
|
|
19
|
+
:strong_post_suffix,
|
|
20
|
+
:shallow,
|
|
21
|
+
:high_confidence_junk_path,
|
|
22
|
+
:high_confidence_utility_destination,
|
|
23
|
+
:selected_anchor_present
|
|
24
|
+
) do
|
|
25
|
+
##
|
|
26
|
+
# @return [Boolean] true when fewer than two positive content signals are present
|
|
27
|
+
def weak_signals?
|
|
28
|
+
[article_container, publish_marker, descriptive_context, content_path].count(&:itself) < 2
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param mode [Symbol] +:strict+ for rank/rank_top; +:lenient+ for select_eligible
|
|
33
|
+
# @return [Boolean] true when the observation should be dropped before ranking
|
|
34
|
+
def hard_junk?(mode: :strict)
|
|
35
|
+
case mode
|
|
36
|
+
when :strict
|
|
37
|
+
high_confidence_junk_path || chrome_title_junk?
|
|
38
|
+
when :lenient
|
|
39
|
+
(high_confidence_junk_path && weak_signals?) || chrome_title_junk?
|
|
40
|
+
else
|
|
41
|
+
raise ArgumentError, "unknown hard_junk mode: #{mode.inspect}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def chrome_title_junk? # rubocop:disable Metrics/CyclomaticComplexity
|
|
48
|
+
(selected_anchor_present && recommended_title && shallow && weak_signals?) ||
|
|
49
|
+
(selected_anchor_present && utility_prefix_title && high_confidence_utility_destination && weak_signals?)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# A segment paired with its composite score.
|
|
7
|
+
RankedSegment = Data.define(:segment, :score) do
|
|
8
|
+
##
|
|
9
|
+
# @param segment [Html2rss::AutoSource::Segment]
|
|
10
|
+
# @param score [Score]
|
|
11
|
+
# @return [RankedSegment]
|
|
12
|
+
# @raise [ArgumentError] on invalid types
|
|
13
|
+
def self.build(segment:, score:)
|
|
14
|
+
raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
|
|
15
|
+
raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
|
|
16
|
+
|
|
17
|
+
new(segment:, score:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# @return [SST::Node]
|
|
22
|
+
def root_node = segment.root_node
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# @return [SST::Node, nil]
|
|
26
|
+
def primary_link = segment.primary_link
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @return [Float]
|
|
30
|
+
def final_score = score.composite
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# @return [Float]
|
|
34
|
+
def quality_score = score.quality
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @return [Float]
|
|
38
|
+
def junk_score = score.junk
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# @return [Integer]
|
|
42
|
+
def position = segment.position
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Scoring
|
|
5
|
+
##
|
|
6
|
+
# Composite score with quality/junk split and optional typed feature breakdown.
|
|
7
|
+
Score = Data.define(:composite, :quality, :junk, :breakdown) do
|
|
8
|
+
##
|
|
9
|
+
# @param composite [Numeric]
|
|
10
|
+
# @param quality [Numeric, nil]
|
|
11
|
+
# @param junk [Numeric, nil]
|
|
12
|
+
# @param breakdown [Hash{Symbol => Numeric}, nil]
|
|
13
|
+
# @return [Score]
|
|
14
|
+
# @raise [ArgumentError] when composite is not numeric
|
|
15
|
+
def self.build(composite:, quality: nil, junk: nil, breakdown: nil)
|
|
16
|
+
raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
|
|
17
|
+
|
|
18
|
+
parts = breakdown.nil? ? Score::EMPTY_BREAKDOWN : breakdown.transform_keys { FeatureId.assert!(_1) }.freeze
|
|
19
|
+
new(
|
|
20
|
+
composite: composite.to_f,
|
|
21
|
+
quality: (quality.nil? ? composite : quality).to_f,
|
|
22
|
+
junk: (junk || 0).to_f,
|
|
23
|
+
breakdown: parts
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
# Shared empty feature breakdown for scores without per-feature tallies.
|
|
28
|
+
Score::EMPTY_BREAKDOWN = {}.freeze
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Declarative feature scoring for AutoSource SST segments.
|
|
6
|
+
module Scoring
|
|
7
|
+
##
|
|
8
|
+
# Closed set of feature identifiers used by the scoring registry.
|
|
9
|
+
module FeatureId
|
|
10
|
+
# Closed set of allowed feature identifier symbols.
|
|
11
|
+
IDS = %i[
|
|
12
|
+
title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
|
|
13
|
+
publish_marker descriptive_context article_container content_tokens
|
|
14
|
+
non_content_utility_path utility_prefix_title_short shallow weak_container
|
|
15
|
+
recommended_title_non_content high_confidence_junk_path junk_tokens
|
|
16
|
+
heading_anchor heading_text_match meaningful_text content_like_destination
|
|
17
|
+
cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
|
|
18
|
+
].to_set.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @param id [Symbol]
|
|
24
|
+
# @return [Symbol]
|
|
25
|
+
# @raise [ArgumentError] when id is not in the closed set
|
|
26
|
+
def assert!(id)
|
|
27
|
+
raise ArgumentError, "unknown FeatureId: #{id.inspect}" unless IDS.include?(id)
|
|
28
|
+
|
|
29
|
+
id
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -92,12 +92,19 @@ module Html2rss
|
|
|
92
92
|
# @param html [String]
|
|
93
93
|
# @param url [String, Html2rss::Url]
|
|
94
94
|
# @return [String, nil]
|
|
95
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
95
96
|
def self.get(html, url)
|
|
96
97
|
return nil if String(html).empty?
|
|
97
98
|
|
|
99
|
+
@fragment_cache ||= {}
|
|
100
|
+
key = [html, url.to_s]
|
|
101
|
+
return @fragment_cache[key] if @fragment_cache.key?(key)
|
|
102
|
+
|
|
103
|
+
@fragment_cache.clear if @fragment_cache.size > 256
|
|
98
104
|
context = Selectors::Context.new(config: { channel: { url: } }, options: {})
|
|
99
|
-
new(html, context).get
|
|
105
|
+
@fragment_cache[key] = new(html, context).get
|
|
100
106
|
end
|
|
107
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
101
108
|
|
|
102
109
|
##
|
|
103
110
|
# @param channel_url [String, Html2rss::Url]
|
|
@@ -117,6 +124,8 @@ module Html2rss
|
|
|
117
124
|
->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
|
|
118
125
|
]
|
|
119
126
|
)
|
|
127
|
+
config[:elements].delete('style')
|
|
128
|
+
config.delete(:css)
|
|
120
129
|
config[:elements].push('audio', 'video', 'source')
|
|
121
130
|
config.freeze
|
|
122
131
|
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
|