html2rss 0.33.0 → 0.34.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 +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -21
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +126 -50
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +49 -6
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +4 -0
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +100 -9
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +0 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +6 -6
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors.rb +53 -12
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class TitlePieces
|
|
6
|
+
##
|
|
7
|
+
# The kicker of one card, first match: the outermost kicker-marked element beside the headline
|
|
8
|
+
# ({KICKER_WORDS}, {KICKER_ATTRIBUTE}) holding no word besides its noise and kicker-marked parts; else
|
|
9
|
+
# a label leaf ({LABEL_WORDS}) opening the heading or anchor text; else the piece right before the
|
|
10
|
+
# headline piece. A paid-content label is never a kicker.
|
|
11
|
+
class Kicker
|
|
12
|
+
# Media element names of both node kinds: a piece holding one is a picture, not a kicker.
|
|
13
|
+
MEDIA_NAMES = Set['img', 'picture', 'figure', 'video', 'svg', :img, :picture, :figure, :video, :svg].freeze
|
|
14
|
+
# Paragraphs of both node kinds: running prose, never a kicker.
|
|
15
|
+
PARAGRAPH_NAMES = Set['p', :p].freeze
|
|
16
|
+
# Class tokens that may hold a {KICKER_WORDS} word: each word with any +-+ or +_+ between its letters,
|
|
17
|
+
# in any case. A cheap gate before the class words are split.
|
|
18
|
+
KICKER_TOKEN = Regexp.new(KICKER_WORDS.map { _1.chars.join('[-_]*') }.join('|'), Regexp::IGNORECASE)
|
|
19
|
+
private_constant :MEDIA_NAMES, :PARAGRAPH_NAMES, :KICKER_TOKEN
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @param nodes [Nodes] reader of the card's nodes
|
|
23
|
+
# @param root [Nokogiri::XML::Node, SST::Node] card root
|
|
24
|
+
# @param source [Nokogiri::XML::Node, SST::Node, nil] headline source: the heading, else the anchor
|
|
25
|
+
# @yieldreturn [Nokogiri::XML::Node, SST::Node, nil] the source's headline piece, read only when
|
|
26
|
+
# no marked or label kicker is found
|
|
27
|
+
def initialize(nodes, root, source, &)
|
|
28
|
+
@nodes = nodes
|
|
29
|
+
@root = root
|
|
30
|
+
@source = source
|
|
31
|
+
@element = marked || (label || preceding(yield) if source)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# @return [Nokogiri::XML::Node, SST::Node, nil] the kicker element
|
|
36
|
+
attr_reader :element
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @return [String, nil] kicker text without its noise pieces ({Article::Title.noise_piece?})
|
|
40
|
+
def text = element && text_without_noise(element)
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# A kicker inside the heading or anchor wins over one beside it (a topline in the heading over a
|
|
45
|
+
# type badge above it).
|
|
46
|
+
def marked
|
|
47
|
+
candidates = @nodes.carrying(@root, KICKER_ATTRIBUTE).select { marked?(_1) && worded?(_1) && beside?(_1) }
|
|
48
|
+
kickers = candidates.select { |node| parts_only?(node, candidates) }
|
|
49
|
+
kickers.find { @nodes.inside?(_1, @source) } || kickers.first
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def marked?(node)
|
|
53
|
+
@nodes.attribute(node, KICKER_ATTRIBUTE) ||
|
|
54
|
+
(@nodes.class_names(node).any?(KICKER_TOKEN) && @nodes.class_words(node).intersect?(KICKER_WORDS))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A label leaf opening the source text, with headline text after it.
|
|
58
|
+
def label
|
|
59
|
+
unseen = @nodes.unseen(@source)
|
|
60
|
+
rest = @nodes.text(@source, unseen).to_s
|
|
61
|
+
@nodes.elements(@source).find do |element|
|
|
62
|
+
label_leaf?(element) && rest.start_with?(@nodes.text(element).to_s) &&
|
|
63
|
+
@nodes.text(@source, unseen + [element]).to_s.match?(WORD)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def label_leaf?(element) = @nodes.class_words(element).intersect?(LABEL_WORDS) && !@nodes.paid_label?(element)
|
|
68
|
+
|
|
69
|
+
# The nearest worded piece before the headline piece, when it is a kicker-shaped leaf.
|
|
70
|
+
def preceding(headline_piece)
|
|
71
|
+
candidate = piece_before(headline_piece)
|
|
72
|
+
return unless candidate && shaped?(candidate)
|
|
73
|
+
|
|
74
|
+
leaf = nearest_leaf(candidate)
|
|
75
|
+
leaf if shaped?(leaf)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Walks from +piece+ up to the source, looking back over element siblings; direct text stops it.
|
|
79
|
+
def piece_before(piece)
|
|
80
|
+
current = piece
|
|
81
|
+
until current.nil? || current.equal?(@source)
|
|
82
|
+
before = @nodes.siblings_before(current)
|
|
83
|
+
return if before.nil?
|
|
84
|
+
|
|
85
|
+
worded = before.reverse.find { !skipped?(_1) }
|
|
86
|
+
return worded if worded
|
|
87
|
+
|
|
88
|
+
current = @nodes.parent(current)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Descends into the last worded child of a piece made only of elements: the leaf next to the
|
|
93
|
+
# headline (a topic span after a badge in one box).
|
|
94
|
+
def nearest_leaf(node)
|
|
95
|
+
until @nodes.own_words?(node)
|
|
96
|
+
child = node.children.reverse.find { !skipped?(_1) }
|
|
97
|
+
return node unless child
|
|
98
|
+
|
|
99
|
+
node = child
|
|
100
|
+
end
|
|
101
|
+
node
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def skipped?(node)
|
|
105
|
+
@nodes.concealed?(node) || @nodes.date_element?(node) || !text_without_noise(node).to_s.match?(WORD)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def shaped?(node)
|
|
109
|
+
!PARAGRAPH_NAMES.include?(node.name) && !@nodes.class_words(node).intersect?(LEAD_WORDS) &&
|
|
110
|
+
@nodes.elements(node).none? { MEDIA_NAMES.include?(_1.name) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def worded?(node)
|
|
114
|
+
!@nodes.date_element?(node) && !@nodes.noise?(node) && !@nodes.concealed?(node) &&
|
|
115
|
+
text_without_noise(node).to_s.match?(WORD)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def text_without_noise(node) = @nodes.text(node, noise(node))
|
|
119
|
+
|
|
120
|
+
# Kicker pieces that are noise on their own: date, time and concealed elements, paid-content labels
|
|
121
|
+
# and {Article::Title.noise_piece?}.
|
|
122
|
+
def noise(node)
|
|
123
|
+
@nodes.elements(node).select do |piece|
|
|
124
|
+
@nodes.date_element?(piece) || @nodes.concealed?(piece) || @nodes.paid_label?(piece) ||
|
|
125
|
+
Article::Title.noise_piece?(@nodes.text(piece))
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def beside?(node)
|
|
130
|
+
return true unless @source
|
|
131
|
+
return false if @nodes.inside?(@source, node)
|
|
132
|
+
|
|
133
|
+
!@nodes.inside?(node, @source) || @nodes.text(@source, [node]).to_s.match?(WORD)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Whether +node+ holds no word outside its noise and its nested kicker candidates.
|
|
137
|
+
def parts_only?(node, candidates)
|
|
138
|
+
parts = candidates.select { |other| !other.equal?(node) && @nodes.inside?(other, node) }
|
|
139
|
+
return true if parts.empty?
|
|
140
|
+
|
|
141
|
+
!@nodes.text(node, noise(node) + parts).to_s.match?(WORD)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
private_constant :Kicker
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class TitlePieces
|
|
6
|
+
##
|
|
7
|
+
# Reads the nodes of one card for {TitlePieces}, SST and Nokogiri alike: element descendants, class
|
|
8
|
+
# words, visible text and the element facts both title rules read (concealed, date and noise
|
|
9
|
+
# elements), each read once per node. {SstNodes} and {NokogiriNodes} supply the reads that differ by
|
|
10
|
+
# node kind; the headline rule stays on {TitlePieces}, the kicker rule on {Kicker}.
|
|
11
|
+
class Nodes
|
|
12
|
+
# Text that holds a word.
|
|
13
|
+
WORD = /[[:alnum:]]/
|
|
14
|
+
# Splits a class token into words.
|
|
15
|
+
CLASS_WORD_BREAK = /[-_]+|(?<=[[:lower:][:digit:]])(?=[[:upper:]])/
|
|
16
|
+
# +time+ element names of both node kinds.
|
|
17
|
+
TIME_NAMES = Set['time', :time].freeze
|
|
18
|
+
private_constant :CLASS_WORD_BREAK, :TIME_NAMES
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# @param root [Nokogiri::XML::Node, SST::Node] card root
|
|
22
|
+
# @return [Nodes] the reader for the node kind of +root+
|
|
23
|
+
def self.for(root) = root.instance_of?(SST::Node) ? SstNodes.new : NokogiriNodes.new
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@words = {}.compare_by_identity
|
|
27
|
+
@elements = {}.compare_by_identity
|
|
28
|
+
@texts = {}.compare_by_identity
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
33
|
+
# @return [Array] element descendants of +node+, read once per node
|
|
34
|
+
def elements(node) = @elements[node] ||= descendants(node)
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# Lowercase words of all class tokens, plus each pair of neighbours joined (+region+ +Name+ gives
|
|
38
|
+
# +regionname+, +sub+ +title+ gives +subtitle+).
|
|
39
|
+
#
|
|
40
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
41
|
+
# @return [Set<String>]
|
|
42
|
+
def class_words(node)
|
|
43
|
+
@words[node] ||= class_names(node).each_with_object(Set.new) do |token, words|
|
|
44
|
+
parts = token.split(CLASS_WORD_BREAK).map(&:downcase)
|
|
45
|
+
words.merge(parts).merge(parts.each_cons(2).map(&:join))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
51
|
+
# @return [Boolean] whether a sighted reader does not see +node+ ({Visibility.concealed?})
|
|
52
|
+
def concealed?(node)
|
|
53
|
+
Visibility.concealed?(class_names(node), style: style(node), hidden: !attribute(node, 'hidden').nil?)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
58
|
+
# @param exclude [Array, nil] elements left out
|
|
59
|
+
# @return [String, nil] visible text of +node+; text read without exclusions is read once per node
|
|
60
|
+
def text(node, exclude = nil)
|
|
61
|
+
return visible_text(node, exclude) if exclude&.any?
|
|
62
|
+
|
|
63
|
+
@texts[node] ||= visible_text(node, nil)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# A date chip set apart from the headline words: a date element ({#date_element?}) with no direct text
|
|
68
|
+
# holding a word right before or right after it (+resume on <time>Friday</time>+ is running text).
|
|
69
|
+
#
|
|
70
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def dropped_date?(node)
|
|
73
|
+
return false unless date_element?(node)
|
|
74
|
+
|
|
75
|
+
before, after = texts_beside(node)
|
|
76
|
+
!before.to_s.match?(WORD) && !after.to_s.match?(WORD)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
81
|
+
# @return [Boolean] whether +node+ is a +time+ element or its class marks a date, time or duration
|
|
82
|
+
# ({DATE_WORDS})
|
|
83
|
+
def date_element?(node) = TIME_NAMES.include?(node.name) || class_words(node).intersect?(DATE_WORDS)
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
87
|
+
# @return [Array] elements of +node+ a reader does not see as its text: concealed and dropped date ones
|
|
88
|
+
def unseen(node) = elements(node).select { concealed?(_1) || dropped_date?(_1) }
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
92
|
+
# @return [Boolean] whether the text of +node+ is a paid-content label
|
|
93
|
+
# ({LinkDestination::Sponsorship.paid_text?})
|
|
94
|
+
def paid_label?(node) = LinkDestination::Sponsorship.paid_text?(text(node))
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# @param node [Nokogiri::XML::Node, SST::Node]
|
|
98
|
+
# @return [Boolean] whether the text of +node+ is headline noise ({Article::Title.headline_noise_piece?})
|
|
99
|
+
# or a paid-content label
|
|
100
|
+
def noise?(node)
|
|
101
|
+
Article::Title.headline_noise_piece?(text(node)) || LinkDestination::Sponsorship.paid_text?(text(node))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
private_constant :Nodes
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class TitlePieces
|
|
6
|
+
##
|
|
7
|
+
# {Nodes} reads of Nokogiri nodes: direct text is the text node siblings of an element.
|
|
8
|
+
class NokogiriNodes < Nodes
|
|
9
|
+
##
|
|
10
|
+
# @param root [Nokogiri::XML::Node]
|
|
11
|
+
# @param attribute [String]
|
|
12
|
+
# @return [Array<Nokogiri::XML::Element>] +root+ and its descendants carrying a class or +attribute+
|
|
13
|
+
def carrying(root, attribute) = root.xpath("descendant-or-self::*[@class or @#{attribute}]")
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @param node [Nokogiri::XML::Node]
|
|
17
|
+
# @return [Array<String>] class tokens
|
|
18
|
+
def class_names(node) = node['class'].to_s.split
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# @param node [Nokogiri::XML::Node]
|
|
22
|
+
# @param name [String]
|
|
23
|
+
# @return [String, nil] the attribute value
|
|
24
|
+
def attribute(node, name) = node[name]
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# @param node [Nokogiri::XML::Node]
|
|
28
|
+
# @return [Nokogiri::XML::Node, nil]
|
|
29
|
+
def parent(node) = node.parent
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param node [Nokogiri::XML::Node]
|
|
33
|
+
# @param ancestor [Nokogiri::XML::Node]
|
|
34
|
+
# @return [Boolean] whether +node+ is +ancestor+ or inside it
|
|
35
|
+
def inside?(node, ancestor) = node == ancestor || Navigator.descendant_of?(node, ancestor)
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @param node [Nokogiri::XML::Node]
|
|
39
|
+
# @return [Boolean] whether +node+'s direct text (outside its child elements) holds a word
|
|
40
|
+
def own_words?(node) = node.children.any? { _1.text? && _1.text.match?(WORD) }
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# @param node [Nokogiri::XML::Node]
|
|
44
|
+
# @return [Array<Nokogiri::XML::Node>, nil] siblings before +node+, or nil when direct text holding a
|
|
45
|
+
# word sits between them and +node+
|
|
46
|
+
def siblings_before(node)
|
|
47
|
+
before = []
|
|
48
|
+
sibling = node.previous_sibling
|
|
49
|
+
while sibling
|
|
50
|
+
return if sibling.text? && sibling.text.match?(WORD)
|
|
51
|
+
|
|
52
|
+
before.unshift(sibling)
|
|
53
|
+
sibling = sibling.previous_sibling
|
|
54
|
+
end
|
|
55
|
+
before
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def descendants(node) = node.css('*')
|
|
61
|
+
def style(node) = node['style']
|
|
62
|
+
def visible_text(node, exclude) = Navigator.extract_visible_text(node, exclude_nodes: exclude)
|
|
63
|
+
|
|
64
|
+
# The direct text right before and right after +node+.
|
|
65
|
+
def texts_beside(node) = [node.previous_sibling, node.next_sibling].map { _1.text if _1&.text? }
|
|
66
|
+
end
|
|
67
|
+
private_constant :NokogiriNodes
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
class TitlePieces
|
|
6
|
+
##
|
|
7
|
+
# {Nodes} reads of SST nodes: direct text is the node's lead text and the tail after each child.
|
|
8
|
+
class SstNodes < Nodes
|
|
9
|
+
##
|
|
10
|
+
# @param root [SST::Node]
|
|
11
|
+
# @param attribute [String]
|
|
12
|
+
# @return [Array<SST::Node>] +root+ and its descendants carrying a class or +attribute+
|
|
13
|
+
def carrying(root, attribute) = root.find_all { _1.attrs.class_names.any? || _1.attrs.raw.key?(attribute) }
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @param node [SST::Node]
|
|
17
|
+
# @return [Array<String>] class tokens
|
|
18
|
+
def class_names(node) = node.attrs.class_names
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# @param node [SST::Node]
|
|
22
|
+
# @param name [String]
|
|
23
|
+
# @return [String, nil] the attribute value
|
|
24
|
+
def attribute(node, name) = node.attrs.raw[name]
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# @param node [SST::Node]
|
|
28
|
+
# @return [SST::Node, nil] the parent of an indexed node
|
|
29
|
+
def parent(node) = SST::Index.for_node(node)&.parent_of(node)
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# @param node [SST::Node]
|
|
33
|
+
# @param ancestor [SST::Node]
|
|
34
|
+
# @return [Boolean] whether +node+ is +ancestor+ or inside it
|
|
35
|
+
def inside?(node, ancestor) = SST::Index.descendant_or_self?(node, ancestor)
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @param node [SST::Node]
|
|
39
|
+
# @return [Boolean] whether +node+'s direct text (outside its child elements) holds a word
|
|
40
|
+
def own_words?(node) = node.own_text.match?(WORD)
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# @param node [SST::Node]
|
|
44
|
+
# @return [Array<SST::Node>, nil] siblings before +node+, or nil when direct text holding a word
|
|
45
|
+
# sits between them and +node+
|
|
46
|
+
def siblings_before(node)
|
|
47
|
+
up = parent(node)
|
|
48
|
+
return unless up
|
|
49
|
+
|
|
50
|
+
index = up.children.index { _1.equal?(node) }
|
|
51
|
+
up.children.take(index) unless [up.lead_text, *up.tails][..index].any? { _1.match?(WORD) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def descendants(node) = node.descendants
|
|
57
|
+
def style(node) = node.attrs.style
|
|
58
|
+
def visible_text(node, exclude) = node.visible_text(exclude:)
|
|
59
|
+
|
|
60
|
+
# The direct text right before and right after +node+.
|
|
61
|
+
def texts_beside(node)
|
|
62
|
+
up = parent(node)
|
|
63
|
+
return [] unless up
|
|
64
|
+
|
|
65
|
+
[up.lead_text, *up.tails][up.children.index { _1.equal?(node) }, 2]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
private_constant :SstNodes
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
##
|
|
6
|
+
# Picks the kicker and the headline text of one teaser card: the one rule shared by
|
|
7
|
+
# {ArticleExtractor} (Nokogiri) and {SstArticleExtractor} (SST), so both give one title for the same
|
|
8
|
+
# markup. The noise vocabulary and composition stay on {Article::Title}; rules: CONTEXT.md § Article title.
|
|
9
|
+
#
|
|
10
|
+
# Everything here reads elements, never headline wording:
|
|
11
|
+
#
|
|
12
|
+
# - Headline: a +data-title+ attribute on the heading or anchor wins. A heading or anchor made only of
|
|
13
|
+
# element pieces (no word in its own direct text) is read from its headline piece: its one outermost
|
|
14
|
+
# headline-marked descendant ({HEADLINE_WORDS}; never a sub-title or a label-marked one, {LABEL_WORDS}),
|
|
15
|
+
# else the selected anchor when it is the only link inside the heading. Pieces beside it (tags, dates,
|
|
16
|
+
# lead paragraphs) are no part of the title.
|
|
17
|
+
# - Headline text leaves out the kicker, concealed elements ({Visibility.concealed?}; they stay when they
|
|
18
|
+
# hold the only words, the accessible name of an otherwise empty link), date and time
|
|
19
|
+
# elements ({Nodes#dropped_date?}) unless direct text holding a word touches them, lead pieces
|
|
20
|
+
# ({LEAD_WORDS}), tag pieces closing the text ({TAG_WORDS}), clock-only or label-only elements before
|
|
21
|
+
# its first word, and date chips closing it ({#date_chip?}).
|
|
22
|
+
# - A paid-content label ({LinkDestination::Sponsorship.paid_text?}) is item metadata, never title text:
|
|
23
|
+
# it is no kicker and leaves the headline text where it opens it, so a kept sponsored item (cleanup
|
|
24
|
+
# +keep_sponsored+) is titled by its headline alone. An ad word ("Anzeige", "Werbung") also names a
|
|
25
|
+
# topic and stays a kicker.
|
|
26
|
+
# - Kicker: {Kicker}.
|
|
27
|
+
class TitlePieces
|
|
28
|
+
# Class words (tokens split on +-+, +_+ and camelCase, neighbours joined) marking kicker / eyebrow
|
|
29
|
+
# text anywhere in the card.
|
|
30
|
+
KICKER_WORDS = Set['kicker', 'eyebrow', 'pretitle', 'overline', 'topline'].freeze
|
|
31
|
+
# Attribute marking kicker text.
|
|
32
|
+
KICKER_ATTRIBUTE = 'data-tb-kicker'
|
|
33
|
+
# Attribute holding the publisher's clean title on the heading or anchor.
|
|
34
|
+
TITLE_ATTRIBUTE = 'data-title'
|
|
35
|
+
# Class words (tokens split on +-+, +_+ and camelCase) marking the headline piece. +subTitle+ and
|
|
36
|
+
# +sub-title+ are not one.
|
|
37
|
+
HEADLINE_WORDS = Set['title', 'headline', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].freeze
|
|
38
|
+
# Class words of a label leaf that is a kicker when it opens the heading or anchor text.
|
|
39
|
+
LABEL_WORDS = (KICKER_WORDS | Set['prefix', 'label', 'alert', 'alerte', 'live', 'regionname']).freeze
|
|
40
|
+
# Class words of date, time and duration elements.
|
|
41
|
+
DATE_WORDS = Set['date', 'time', 'duration', 'timestamp', 'datetime'].freeze
|
|
42
|
+
# Class words of a teaser lead or summary, never part of the title.
|
|
43
|
+
LEAD_WORDS = Set['lead', 'leadin', 'summary', 'excerpt', 'standfirst', 'dek', 'abstract'].freeze
|
|
44
|
+
# Class words of tag or subtitle pieces, dropped when they close the headline text.
|
|
45
|
+
TAG_WORDS = Set['tag', 'tags', 'subtitle'].freeze
|
|
46
|
+
|
|
47
|
+
# Text that holds a word.
|
|
48
|
+
WORD = Nodes::WORD
|
|
49
|
+
# Link element names of both node kinds.
|
|
50
|
+
LINK_NAMES = Set['a', :a].freeze
|
|
51
|
+
private_constant :WORD, :LINK_NAMES
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# @param root [Nokogiri::XML::Node, SST::Node] card root
|
|
55
|
+
# @param source [Nokogiri::XML::Node, SST::Node, nil] headline source: the heading, else the anchor
|
|
56
|
+
# @param anchor [Nokogiri::XML::Node, SST::Node, nil] the card's selected anchor
|
|
57
|
+
def initialize(root, source, anchor: nil)
|
|
58
|
+
@anchor = anchor
|
|
59
|
+
@nodes = Nodes.for(root)
|
|
60
|
+
@kicker = Kicker.new(@nodes, root, source) { headline_piece(source) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# @return [Nokogiri::XML::Node, SST::Node, nil] the kicker element ({Kicker})
|
|
65
|
+
def kicker = @kicker.element
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @return [String, nil] kicker text without its noise pieces ({Article::Title.noise_piece?})
|
|
69
|
+
def kicker_text = @kicker.text
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# @param node [Nokogiri::XML::Node, SST::Node, nil] heading or anchor
|
|
73
|
+
# @return [String, nil] its headline text (see the class notes)
|
|
74
|
+
def headline_text(node)
|
|
75
|
+
return unless node
|
|
76
|
+
return @nodes.attribute(node, TITLE_ATTRIBUTE) if @nodes.attribute(node, TITLE_ATTRIBUTE).to_s.match?(WORD)
|
|
77
|
+
|
|
78
|
+
node = headline_piece(node) || node
|
|
79
|
+
@nodes.text(node, headline_drops(node))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# --- Headline ------------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
# The piece of +node+ holding the headline when +node+ is made only of element pieces.
|
|
87
|
+
def headline_piece(node)
|
|
88
|
+
return if @nodes.own_words?(node)
|
|
89
|
+
|
|
90
|
+
marked = @nodes.elements(node).select { headline_marked?(_1) }
|
|
91
|
+
outermost = marked.reject { |el| marked.any? { |other| !other.equal?(el) && @nodes.inside?(el, other) } }
|
|
92
|
+
return outermost.first if outermost.one?
|
|
93
|
+
|
|
94
|
+
@anchor if sole_anchor_inside?(node)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Whether the selected anchor is the only link inside +node+ (a headline split over several links
|
|
98
|
+
# stays whole).
|
|
99
|
+
def sole_anchor_inside?(node)
|
|
100
|
+
links = @nodes.elements(node).select { LINK_NAMES.include?(_1.name) }
|
|
101
|
+
links.one? && links.first.equal?(@anchor)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def headline_marked?(node)
|
|
105
|
+
words = @nodes.class_words(node)
|
|
106
|
+
words.intersect?(HEADLINE_WORDS) && !words.include?('subtitle') && !words.intersect?(LABEL_WORDS)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Elements left out of the headline text of +node+, in rule order.
|
|
110
|
+
def headline_drops(node)
|
|
111
|
+
dropped = drop_concealed!(node, [kicker, *@nodes.elements(node).select { @nodes.dropped_date?(_1) }].compact)
|
|
112
|
+
drop_pieces!(node, dropped, LEAD_WORDS) { |rest, _| rest.match?(WORD) }
|
|
113
|
+
drop_pieces!(node, dropped, TAG_WORDS) { |rest, piece| rest.match?(WORD) && closing?(node, dropped, piece) }
|
|
114
|
+
drop_opening_noise!(node, dropped)
|
|
115
|
+
drop_closing_dates!(node, dropped)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Concealed elements leave unless they hold the only words (the name of an otherwise empty link).
|
|
119
|
+
def drop_concealed!(node, dropped)
|
|
120
|
+
concealed = @nodes.elements(node).select { @nodes.concealed?(_1) }
|
|
121
|
+
@nodes.text(node, dropped + concealed).to_s.match?(WORD) ? dropped.concat(concealed) : dropped
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Drops each +words+-marked piece of +node+ the block admits, given the text left without it.
|
|
125
|
+
def drop_pieces!(node, dropped, words)
|
|
126
|
+
@nodes.elements(node).each do |element|
|
|
127
|
+
next unless @nodes.class_words(element).intersect?(words)
|
|
128
|
+
|
|
129
|
+
dropped << element if yield(@nodes.text(node, dropped + [element]).to_s, element)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def drop_opening_noise!(node, dropped)
|
|
134
|
+
@nodes.elements(node).each do |element|
|
|
135
|
+
next if within?(element, dropped) || !@nodes.noise?(element)
|
|
136
|
+
break unless opening?(node, dropped, element)
|
|
137
|
+
|
|
138
|
+
dropped << element
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Date chips ({#date_chip?}) closing the text.
|
|
143
|
+
def drop_closing_dates!(node, dropped)
|
|
144
|
+
@nodes.elements(node).reverse_each do |element|
|
|
145
|
+
next if within?(element, dropped) || !date_chip?(element)
|
|
146
|
+
break unless closing?(node, dropped, element)
|
|
147
|
+
|
|
148
|
+
dropped << element
|
|
149
|
+
end
|
|
150
|
+
dropped
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The date chip of a teaser card: a date-only element ({Article::Title.date_piece?}), or a date element
|
|
154
|
+
# holding only a clock time (+late <time>22:10</time>+, while +at <b>20:15</b>+ is headline text).
|
|
155
|
+
def date_chip?(element)
|
|
156
|
+
Article::Title.date_piece?(@nodes.text(element)) || (@nodes.date_element?(element) && Article::Title.clock_piece?(@nodes.text(element)))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Whether +element+ opens the text of +node+ left after +dropped+.
|
|
160
|
+
def opening?(node, dropped, element) = @nodes.text(node, dropped).to_s.start_with?(@nodes.text(element))
|
|
161
|
+
|
|
162
|
+
# Whether +element+ closes the text of +node+ left after +dropped+.
|
|
163
|
+
def closing?(node, dropped, element) = @nodes.text(node, dropped).end_with?(@nodes.text(element).to_s)
|
|
164
|
+
|
|
165
|
+
# Whether +element+ is or sits inside a dropped element.
|
|
166
|
+
def within?(element, dropped) = dropped.any? { @nodes.inside?(element, _1) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
##
|
|
6
|
+
# The one vocabulary of elements a sighted reader does not see, in two kinds:
|
|
7
|
+
#
|
|
8
|
+
# - unrendered: a +hidden+ attribute, an inline +display: none+, or a hiding utility class
|
|
9
|
+
# ({HIDDEN_CLASSES}). Neither seen nor spoken.
|
|
10
|
+
# - screen-reader-only: an off-screen or visually-hidden class ({SCREEN_READER_CLASS}: +sr-only+,
|
|
11
|
+
# +visually-hidden+, +h-offscreen+, +_screen-reader-only+, +screen-reader-text+, ...). Spoken, not seen.
|
|
12
|
+
#
|
|
13
|
+
# +aria-hidden="true"+ is neither: the element is seen, only not spoken. Callers pick the kind they
|
|
14
|
+
# need: a sponsorship label still counts when spoken ({LinkDestination::Sponsorship}), while titles
|
|
15
|
+
# and item anchors keep only what is seen ({.concealed?}). Inputs are plain attribute values, so
|
|
16
|
+
# SST and Nokogiri callers share it.
|
|
17
|
+
module Visibility
|
|
18
|
+
# Inline style that hides an element.
|
|
19
|
+
HIDDEN_STYLE = /display\s*:\s*none/i
|
|
20
|
+
# Utility class tokens that keep an element from rendering.
|
|
21
|
+
HIDDEN_CLASSES = Set['hidden', 'd-none', 'is-hidden', 'hide'].freeze
|
|
22
|
+
# Off-screen / visually-hidden class token, with an optional utility or BEM prefix (+u-+, +h-+,
|
|
23
|
+
# +card__+, a leading +_+) and a +-focusable+ variant that shows only on keyboard focus.
|
|
24
|
+
SCREEN_READER_CLASS = /
|
|
25
|
+
\A_?(?:[a-z0-9]+(?:-|__))?
|
|
26
|
+
(?:sr-only|visually-?hidden|screen-?reader-(?:only|text)|off-?screen|element-invisible)
|
|
27
|
+
(?:-focusable)?\z
|
|
28
|
+
/ix
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
##
|
|
32
|
+
# @param class_names [Array<String>] class tokens
|
|
33
|
+
# @param style [String, nil] inline style
|
|
34
|
+
# @param hidden [Boolean] whether the element carries a +hidden+ attribute
|
|
35
|
+
# @return [Boolean] whether the element is not rendered at all
|
|
36
|
+
def unrendered?(class_names, style:, hidden:)
|
|
37
|
+
hidden || style.to_s.match?(HIDDEN_STYLE) || class_names.any? { HIDDEN_CLASSES.include?(_1) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# @param class_names [Array<String>] class tokens
|
|
42
|
+
# @return [Boolean] whether a class keeps the element off screen for screen readers only
|
|
43
|
+
def screen_reader_only?(class_names) = class_names.any? { _1.match?(SCREEN_READER_CLASS) }
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @param class_names [Array<String>] class tokens
|
|
47
|
+
# @param style [String, nil] inline style
|
|
48
|
+
# @param hidden [Boolean] whether the element carries a +hidden+ attribute
|
|
49
|
+
# @return [Boolean] whether a sighted reader does not see the element
|
|
50
|
+
def concealed?(class_names, style:, hidden:)
|
|
51
|
+
unrendered?(class_names, style:, hidden:) || screen_reader_only?(class_names)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/html2rss/html.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Html2rss
|
|
|
21
21
|
# @param url [Html2rss::Url] normalized destination URL
|
|
22
22
|
# @return [DestinationFacts] route facts for downstream link scoring
|
|
23
23
|
def self.build(url) # rubocop:disable Metrics/MethodLength
|
|
24
|
-
classifier =
|
|
24
|
+
classifier = classifier_for(url)
|
|
25
25
|
|
|
26
26
|
new(
|
|
27
27
|
url:,
|
|
@@ -37,6 +37,22 @@ module Html2rss
|
|
|
37
37
|
high_confidence_utility_destination: classifier.utility_destination?
|
|
38
38
|
)
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
# @param url [Html2rss::Url]
|
|
42
|
+
# @return [PathClassifier] path classifier that also sees the first host label (an obituary subdomain)
|
|
43
|
+
def self.classifier_for(url)
|
|
44
|
+
PathClassifier.new(url.path_segments.map { decode_segment(_1) },
|
|
45
|
+
host_label: url.host.to_s.split('.').first, query: !url.query.nil?)
|
|
46
|
+
end
|
|
47
|
+
private_class_method :classifier_for
|
|
48
|
+
|
|
49
|
+
# Url keeps path segments percent-encoded; classification words are plain NFC text.
|
|
50
|
+
# @param segment [String] percent-encoded path segment
|
|
51
|
+
# @return [String] decoded, NFC-normalized segment
|
|
52
|
+
def self.decode_segment(segment)
|
|
53
|
+
Addressable::URI.unencode_component(segment).force_encoding(Encoding::UTF_8).scrub.unicode_normalize(:nfc)
|
|
54
|
+
end
|
|
55
|
+
private_class_method :decode_segment
|
|
40
56
|
end
|
|
41
57
|
end
|
|
42
58
|
end
|