html2rss 0.23.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/README.md +2 -2
- data/lib/html2rss/article.rb +42 -35
- 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/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- 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/channel.rb +159 -79
- 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/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
- data/lib/html2rss/feed_builder/json_feed.rb +22 -2
- data/lib/html2rss/feed_builder/rss.rb +36 -24
- data/lib/html2rss/feed_builder.rb +9 -17
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
- data/lib/html2rss/feed_pipeline.rb +66 -55
- data/lib/html2rss/feed_result.rb +92 -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 +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/botasaurus_contract.rb +33 -5
- data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
- data/lib/html2rss/request_service/budget.rb +7 -2
- data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/strategy.rb +22 -0
- data/lib/html2rss/request_session.rb +4 -0
- 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 +26 -24
- 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/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +41 -29
- data/schema/html2rss-config.schema.json +20 -0
- metadata +45 -19
- 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/auto_source/scraper/schema/list_item.rb +0 -28
|
@@ -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
|
@@ -31,6 +31,11 @@ module Html2rss
|
|
|
31
31
|
ITEM_TAGS = %i[title url description author comments published_at guid enclosure categories].freeze
|
|
32
32
|
# Item attributes that require dedicated extraction logic.
|
|
33
33
|
SPECIAL_ATTRIBUTES = Set[:guid, :enclosure, :categories].freeze
|
|
34
|
+
# Config selector keys that map onto a different {Article} attribute.
|
|
35
|
+
# +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
|
|
36
|
+
SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
|
|
37
|
+
# Selector keys that may be copied onto an Article (PROVIDED_KEYS + mapped aliases).
|
|
38
|
+
SELECTABLE_SELECTOR_KEYS = (Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys).to_set.freeze
|
|
34
39
|
|
|
35
40
|
##
|
|
36
41
|
# Initializes a new Selectors instance.
|
|
@@ -43,9 +48,7 @@ module Html2rss
|
|
|
43
48
|
@url = response.url
|
|
44
49
|
@selectors = selectors
|
|
45
50
|
@time_zone = time_zone
|
|
46
|
-
|
|
47
|
-
prepare_selectors!
|
|
48
|
-
@rss_item_attributes = @selectors.keys & Html2rss::Article::PROVIDED_KEYS
|
|
51
|
+
@rss_item_attributes = @selectors.keys.select { |key| SELECTABLE_SELECTOR_KEYS.include?(key) }
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
##
|
|
@@ -92,7 +95,13 @@ module Html2rss
|
|
|
92
95
|
# @return [Hash] Hash of attributes for the article.
|
|
93
96
|
def extract_article(item, page_response = response)
|
|
94
97
|
scope = item_scope_for(item, page_response.url)
|
|
95
|
-
@rss_item_attributes.
|
|
98
|
+
@rss_item_attributes.each_with_object({}) do |selector_key, hash|
|
|
99
|
+
value = scope.select(selector_key)
|
|
100
|
+
next if value.nil?
|
|
101
|
+
|
|
102
|
+
article_key = SELECTOR_TO_ARTICLE_KEY.fetch(selector_key, selector_key)
|
|
103
|
+
hash[article_key] = article_key == :enclosures ? wrap_enclosure_value(value) : value
|
|
104
|
+
end
|
|
96
105
|
end
|
|
97
106
|
|
|
98
107
|
##
|
|
@@ -169,24 +178,6 @@ module Html2rss
|
|
|
169
178
|
)
|
|
170
179
|
end
|
|
171
180
|
|
|
172
|
-
def prepare_selectors!
|
|
173
|
-
validate_url_and_link_exclusivity!
|
|
174
|
-
fix_url_and_link!
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def validate_url_and_link_exclusivity!
|
|
178
|
-
return unless @selectors.key?(:url) && @selectors.key?(:link)
|
|
179
|
-
|
|
180
|
-
raise InvalidSelectorName, 'You must either use "url" or "link" your selectors. Using both is not supported.'
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def fix_url_and_link!
|
|
184
|
-
return if @selectors[:url] || !@selectors.key?(:link)
|
|
185
|
-
|
|
186
|
-
@selectors = @selectors.dup
|
|
187
|
-
@selectors[:url] = @selectors[:link]
|
|
188
|
-
end
|
|
189
|
-
|
|
190
181
|
def parsed_body
|
|
191
182
|
parsed_body_for(response)
|
|
192
183
|
end
|
|
@@ -308,9 +299,20 @@ module Html2rss
|
|
|
308
299
|
@channel_contexts[base_url] ||= { url: base_url, time_zone: @time_zone }.freeze
|
|
309
300
|
end
|
|
310
301
|
|
|
311
|
-
#
|
|
302
|
+
# Keep a single enclosure Hash as one list entry; +Array(hash)+ would split pairs.
|
|
303
|
+
#
|
|
304
|
+
# @param value [Hash, Array] enclosure hash or list of hashes
|
|
305
|
+
# @return [Array]
|
|
306
|
+
def wrap_enclosure_value(value)
|
|
307
|
+
value.is_a?(Array) ? value : [value]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# @return [Hash, nil] enclosure details, or nil when the selector yields nothing.
|
|
312
311
|
def enclosure(scope:, config:)
|
|
313
|
-
|
|
312
|
+
selected = select_regular(:enclosure, scope:, config:)
|
|
313
|
+
return if selected.nil? || selected.to_s.strip.empty?
|
|
314
|
+
|
|
315
|
+
url = Url.from_relative(selected, scope.base_url)
|
|
314
316
|
|
|
315
317
|
{ url:, type: config[:content_type] }
|
|
316
318
|
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
|