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,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
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module SST
|
|
7
|
+
##
|
|
8
|
+
# Sole Nokogiri consumer on the heuristic auto-source path. Builds an
|
|
9
|
+
# immutable SST::Document with parent/depth/chrome indices.
|
|
10
|
+
class Normalizer # rubocop:disable Metrics/ClassLength
|
|
11
|
+
# Hard ceiling for SST node allocations; beyond this we degrade.
|
|
12
|
+
MAX_NODES = 5_000
|
|
13
|
+
|
|
14
|
+
# Tags stripped entirely from the SST.
|
|
15
|
+
STRIPPED_TAGS = Set['script', 'style', 'noscript', 'iframe', 'svg', 'template'].freeze
|
|
16
|
+
|
|
17
|
+
# Kept when MAX_NODES forces semantic-tag-only degrade.
|
|
18
|
+
SEMANTIC_DEGRADE_TAGS = Set['html', 'body', 'article', 'section', 'li', 'tr', 'div', 'a',
|
|
19
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'time', 'img', 'p',
|
|
20
|
+
'ul', 'ol', 'main'].freeze
|
|
21
|
+
|
|
22
|
+
# Attribute names preserved in Attrs#raw for extraction.
|
|
23
|
+
RAW_ATTR_KEEP = /
|
|
24
|
+
\A(
|
|
25
|
+
data- |
|
|
26
|
+
aria- |
|
|
27
|
+
class |
|
|
28
|
+
id |
|
|
29
|
+
href |
|
|
30
|
+
src |
|
|
31
|
+
srcset |
|
|
32
|
+
style |
|
|
33
|
+
datetime |
|
|
34
|
+
itemprop |
|
|
35
|
+
type |
|
|
36
|
+
category |
|
|
37
|
+
categories |
|
|
38
|
+
tag |
|
|
39
|
+
tags |
|
|
40
|
+
topic |
|
|
41
|
+
topics |
|
|
42
|
+
section |
|
|
43
|
+
sections |
|
|
44
|
+
label |
|
|
45
|
+
labels |
|
|
46
|
+
theme |
|
|
47
|
+
themes |
|
|
48
|
+
subject |
|
|
49
|
+
subjects
|
|
50
|
+
)
|
|
51
|
+
/xi
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
##
|
|
55
|
+
# @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
|
|
56
|
+
# HTML string or already-parsed node (String is parsed once here)
|
|
57
|
+
# @return [Document]
|
|
58
|
+
# @raise [ArgumentError] when input is nil or unsupported
|
|
59
|
+
def call(input)
|
|
60
|
+
raise ArgumentError, 'input is required' if input.nil?
|
|
61
|
+
|
|
62
|
+
new(coerce(input)).call
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# @param input [String, Nokogiri::XML::Node]
|
|
68
|
+
# @return [Nokogiri::XML::Node]
|
|
69
|
+
def coerce(input)
|
|
70
|
+
case input
|
|
71
|
+
when String then Nokogiri::HTML(input)
|
|
72
|
+
when Nokogiri::XML::Node then input
|
|
73
|
+
else
|
|
74
|
+
raise ArgumentError, "expected String or Nokogiri::XML::Node, got #{input.class}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param parsed_body [Nokogiri::HTML::Document, Nokogiri::XML::Node]
|
|
80
|
+
def initialize(parsed_body)
|
|
81
|
+
@parsed_body = parsed_body
|
|
82
|
+
@node_count = 0
|
|
83
|
+
@degraded = false
|
|
84
|
+
@parents = {}.compare_by_identity
|
|
85
|
+
@depths = {}.compare_by_identity
|
|
86
|
+
@ignored_chrome = {}.compare_by_identity
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
##
|
|
90
|
+
# @return [Document]
|
|
91
|
+
def call
|
|
92
|
+
root_nk = @parsed_body.respond_to?(:root) ? (@parsed_body.at_css('html') || @parsed_body.root) : @parsed_body
|
|
93
|
+
root = normalize_element(root_nk, parent: nil, depth: 0, path: '', chrome: false)
|
|
94
|
+
raise ArgumentError, 'SST Normalizer produced an empty tree' unless root
|
|
95
|
+
|
|
96
|
+
index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
|
|
97
|
+
Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
103
|
+
def normalize_element(nk_node, parent:, depth:, path:, chrome:)
|
|
104
|
+
return unless nk_node.respond_to?(:name)
|
|
105
|
+
return if nk_node.text? || nk_node.comment? || nk_node.cdata?
|
|
106
|
+
|
|
107
|
+
tag = nk_node.name.to_s.downcase
|
|
108
|
+
return if STRIPPED_TAGS.include?(tag)
|
|
109
|
+
return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
|
|
110
|
+
|
|
111
|
+
if @node_count >= MAX_NODES && !@degraded
|
|
112
|
+
@degraded = true
|
|
113
|
+
Html2rss::Log.warn("sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
|
|
117
|
+
|
|
118
|
+
@node_count += 1
|
|
119
|
+
tag_path = path.empty? ? "/#{tag}" : "#{path}/#{tag}"
|
|
120
|
+
attrs = extract_attrs(nk_node)
|
|
121
|
+
own_text = direct_text(nk_node)
|
|
122
|
+
chrome_here = chrome || Tags::IGNORED_CONTAINER_NAMES.include?(tag.to_sym)
|
|
123
|
+
|
|
124
|
+
children = nk_node.children.filter_map do |child|
|
|
125
|
+
normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
|
|
126
|
+
end.freeze
|
|
127
|
+
|
|
128
|
+
node = Node.build(name: tag, attrs:, own_text:, children:, tag_path:)
|
|
129
|
+
@parents[node] = parent == :pending ? nil : parent
|
|
130
|
+
@depths[node] = depth
|
|
131
|
+
@ignored_chrome[node] = chrome_here
|
|
132
|
+
|
|
133
|
+
# Fix parent pointers now that node exists.
|
|
134
|
+
children.each { |child| @parents[child] = node }
|
|
135
|
+
|
|
136
|
+
node
|
|
137
|
+
end
|
|
138
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
139
|
+
|
|
140
|
+
def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
|
|
141
|
+
Attrs.build(
|
|
142
|
+
href: nk_node['href'],
|
|
143
|
+
src: nk_node['src'],
|
|
144
|
+
id: nk_node['id'],
|
|
145
|
+
class_names: nk_node['class'].to_s.split(/\s+/).reject(&:empty?),
|
|
146
|
+
datetime: nk_node['datetime'],
|
|
147
|
+
itemprop: nk_node['itemprop'],
|
|
148
|
+
style: nk_node['style'],
|
|
149
|
+
srcset: nk_node['srcset'],
|
|
150
|
+
type: nk_node['type'],
|
|
151
|
+
raw: raw_attrs(nk_node)
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def raw_attrs(nk_node)
|
|
156
|
+
typed = %w[href src id class datetime itemprop style srcset type].to_set
|
|
157
|
+
nk_node.attribute_nodes.each_with_object({}) do |attr, raw|
|
|
158
|
+
name = attr.name.to_s
|
|
159
|
+
next if typed.include?(name)
|
|
160
|
+
next unless name.match?(RAW_ATTR_KEEP)
|
|
161
|
+
|
|
162
|
+
raw[name] = attr.value.to_s
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def direct_text(nk_node)
|
|
167
|
+
nk_node.children.select(&:text?).map(&:text).join
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module SST
|
|
5
|
+
##
|
|
6
|
+
# Shared tag-name and href constants for SST nodes.
|
|
7
|
+
module Tags
|
|
8
|
+
# Heading element names.
|
|
9
|
+
HEADING_NAMES = %i[h1 h2 h3 h4 h5 h6].to_set.freeze
|
|
10
|
+
# Block-level names that contribute structural newlines in text.
|
|
11
|
+
BLOCK_NAMES = %i[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
|
|
12
|
+
# Names skipped when collecting visible text.
|
|
13
|
+
INVISIBLE_NAMES = %i[svg script noscript style template].to_set.freeze
|
|
14
|
+
# Landmark names treated as utility chrome.
|
|
15
|
+
UTILITY_LANDMARK_NAMES = %i[nav aside footer menu].to_set.freeze
|
|
16
|
+
# Container names ignored as article roots.
|
|
17
|
+
IGNORED_CONTAINER_NAMES = %i[nav footer header svg script style].to_set.freeze
|
|
18
|
+
# Names excluded from class/structure clustering.
|
|
19
|
+
CLUSTER_EXCLUDED_NAMES = %i[html body nav footer header svg script style].to_set.freeze
|
|
20
|
+
# Href prefixes that never count as content destinations.
|
|
21
|
+
SKIP_HREF_PREFIXES = ['#', 'javascript:', 'mailto:', 'tel:', 'file://', 'sms:', 'data:'].freeze
|
|
22
|
+
# Layout-ish names used when resolving nested wrapper groups.
|
|
23
|
+
LAYOUT_NAMES = %i[div section article li ul ol].to_set.freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module SST
|
|
5
|
+
##
|
|
6
|
+
# Visible-text extraction for SST nodes (port of Navigator::TextExtractor).
|
|
7
|
+
module Text
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# @param node [Node]
|
|
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
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
parts = collect_parts(node, separator, excluded)
|
|
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
|
|
31
|
+
|
|
32
|
+
def collect_parts(node, separator, excluded)
|
|
33
|
+
last_block = false
|
|
34
|
+
node.children.each_with_object([]) do |child, parts|
|
|
35
|
+
next if excluded&.[](child)
|
|
36
|
+
next unless visible_child?(child)
|
|
37
|
+
|
|
38
|
+
text, block = child_text_and_block(child, separator, excluded)
|
|
39
|
+
next if text.empty?
|
|
40
|
+
|
|
41
|
+
append_separator!(parts, separator, block, last_block)
|
|
42
|
+
parts << text
|
|
43
|
+
last_block = block
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
module_function :collect_parts
|
|
47
|
+
private_class_method :collect_parts
|
|
48
|
+
|
|
49
|
+
def child_text_and_block(child, separator, excluded)
|
|
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
|
|
60
|
+
|
|
61
|
+
def append_separator!(parts, separator, is_block, last_was_block)
|
|
62
|
+
return if parts.empty?
|
|
63
|
+
|
|
64
|
+
parts << if is_block || last_was_block
|
|
65
|
+
(separator == ' ' ? "\n" : separator)
|
|
66
|
+
else
|
|
67
|
+
' '
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
module_function :append_separator!
|
|
71
|
+
private_class_method :append_separator!
|
|
72
|
+
|
|
73
|
+
def visible_child?(node)
|
|
74
|
+
!Tags::INVISIBLE_NAMES.include?(node.name) &&
|
|
75
|
+
!(node.name == :a && node.attrs.href&.start_with?('#'))
|
|
76
|
+
end
|
|
77
|
+
module_function :visible_child?
|
|
78
|
+
private_class_method :visible_child?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/html2rss/sst.rb
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
|
|
6
|
+
#
|
|
7
|
+
# Exposed publicly via {FeedResult#status}. Safe to log without reading articles.
|
|
8
|
+
# Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
|
|
9
|
+
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
10
|
+
Status = Data.define(
|
|
11
|
+
:version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
|
|
12
|
+
) do
|
|
13
|
+
class << self
|
|
14
|
+
##
|
|
15
|
+
# Builds status from extracted articles and scrape telemetry.
|
|
16
|
+
#
|
|
17
|
+
# @param articles [Array<Html2rss::Article>] articles kept after deduplication
|
|
18
|
+
# @param dedup_dropped [Integer] number of articles removed by deduplication
|
|
19
|
+
# @param selected_strategy [Symbol, nil] concrete strategy that succeeded under +:auto+ (else +nil+)
|
|
20
|
+
# @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
|
|
21
|
+
# @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
|
|
22
|
+
# @return [Html2rss::Status]
|
|
23
|
+
def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0, strategy_attempts: [])
|
|
24
|
+
tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
|
|
25
|
+
new(
|
|
26
|
+
version: Html2rss::VERSION,
|
|
27
|
+
scraper_tallies: tallies,
|
|
28
|
+
dedup_dropped:,
|
|
29
|
+
selected_strategy:,
|
|
30
|
+
attempt_count:,
|
|
31
|
+
strategy_attempts:
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @param klass [Class, #to_s] scraper class
|
|
37
|
+
# @return [String] short scraper label for tallies / generator text
|
|
38
|
+
def scraper_name(klass)
|
|
39
|
+
klass.to_s.gsub(/(?:Html2rss|Scraper)::/, '')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @param version [String]
|
|
45
|
+
# @param scraper_tallies [Hash{String => Integer}]
|
|
46
|
+
# @param dedup_dropped [Integer]
|
|
47
|
+
# @param selected_strategy [Symbol, nil]
|
|
48
|
+
# @param attempt_count [Integer]
|
|
49
|
+
# @param strategy_attempts [Array<Hash>]
|
|
50
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
|
|
51
|
+
def initialize(
|
|
52
|
+
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
|
|
53
|
+
)
|
|
54
|
+
dedup = Integer(dedup_dropped)
|
|
55
|
+
attempts = Integer(attempt_count)
|
|
56
|
+
validate_counters!(dedup:, attempts:, selected_strategy:)
|
|
57
|
+
|
|
58
|
+
super(
|
|
59
|
+
version: version.to_s.dup.freeze,
|
|
60
|
+
scraper_tallies: freeze_tallies(scraper_tallies),
|
|
61
|
+
dedup_dropped: dedup,
|
|
62
|
+
selected_strategy:,
|
|
63
|
+
attempt_count: attempts,
|
|
64
|
+
strategy_attempts: freeze_attempts(strategy_attempts)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
|
|
71
|
+
# +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
|
|
72
|
+
# +:strategy_attempts+ when empty.
|
|
73
|
+
# Data members remain available via readers even when omitted here.
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
|
|
76
|
+
# optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+, +:strategy_attempts+
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
version:,
|
|
80
|
+
dedup_dropped:,
|
|
81
|
+
**(scraper_tallies.any? ? { scraper_tallies: } : {}),
|
|
82
|
+
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
83
|
+
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
84
|
+
**(strategy_attempts.any? ? { strategy_attempts: } : {})
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# Formats the RSS +generator+ string and JSON Feed +user_comment+.
|
|
90
|
+
# Scraper-focused only — auto strategy summary stays on {#to_h}, not this string.
|
|
91
|
+
# Omits the +(scrapers: …)+ clause when tallies are empty.
|
|
92
|
+
#
|
|
93
|
+
# @return [String]
|
|
94
|
+
def to_generator_comment
|
|
95
|
+
return "html2rss V. #{version}" if scraper_tallies.empty?
|
|
96
|
+
|
|
97
|
+
counts = scraper_tallies.map { |name, count| "#{name} (#{count})" }
|
|
98
|
+
"html2rss V. #{version} (scrapers: #{counts.join(', ')})"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def marshal_dump
|
|
104
|
+
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts))
|
|
108
|
+
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:, strategy_attempts:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def freeze_tallies(tallies)
|
|
112
|
+
tallies.to_h.transform_keys(&:to_s).transform_values { |count| Integer(count) }.freeze
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def freeze_attempts(attempts)
|
|
116
|
+
Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_counters!(dedup:, attempts:, selected_strategy:)
|
|
120
|
+
raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
|
|
121
|
+
raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
|
|
122
|
+
|
|
123
|
+
unless selected_strategy.nil? || selected_strategy.is_a?(Symbol)
|
|
124
|
+
raise ArgumentError, 'selected_strategy must be a Symbol or nil'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
return unless selected_strategy && attempts < 1
|
|
128
|
+
|
|
129
|
+
raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set'
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|