html2rss 0.32.4 → 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 -25
- 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 +127 -53
- 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 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- 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 +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- 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 +50 -7
- 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 +16 -34
- 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 +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- 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 +55 -51
- 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 +111 -42
- 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 +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- 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 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- 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 +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -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 +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- 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/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- 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 +9 -10
- 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 +5 -6
- 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/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- 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 +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
data/lib/html2rss/sst/index.rb
CHANGED
|
@@ -14,18 +14,32 @@ module Html2rss
|
|
|
14
14
|
# @return [Index, nil]
|
|
15
15
|
def self.for_node(node) = NODE_BINDINGS[node]
|
|
16
16
|
|
|
17
|
+
##
|
|
18
|
+
# Whether +node+ is +ancestor+ or inside it: parent pointers when +node+ is indexed, else a
|
|
19
|
+
# scan of +ancestor+'s subtree.
|
|
20
|
+
#
|
|
21
|
+
# @param node [Node]
|
|
22
|
+
# @param ancestor [Node]
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def self.descendant_or_self?(node, ancestor)
|
|
25
|
+
return true if node.equal?(ancestor)
|
|
26
|
+
|
|
27
|
+
index = for_node(node)
|
|
28
|
+
index ? index.descendant_of?(node, ancestor) : !ancestor.find { _1.equal?(node) }.nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
17
31
|
##
|
|
18
32
|
# @param root [Node]
|
|
19
33
|
# @param parents [Hash{Node => Node, nil}]
|
|
20
34
|
# @param depths [Hash{Node => Integer}]
|
|
21
|
-
# @param
|
|
22
|
-
def initialize(root:, parents:, depths:,
|
|
35
|
+
# @param scopes [Hash{Node => Symbol}] {Tags.chrome_scope} of each node
|
|
36
|
+
def initialize(root:, parents:, depths:, scopes:)
|
|
23
37
|
@root = root
|
|
24
38
|
@parents = parents
|
|
25
39
|
@depths = depths
|
|
26
|
-
@
|
|
40
|
+
@scopes = scopes
|
|
27
41
|
@visible_text = {}.compare_by_identity
|
|
28
|
-
@
|
|
42
|
+
@run_count = {}.compare_by_identity
|
|
29
43
|
bind_tree(root)
|
|
30
44
|
end
|
|
31
45
|
|
|
@@ -44,7 +58,14 @@ module Html2rss
|
|
|
44
58
|
##
|
|
45
59
|
# @param node [Node]
|
|
46
60
|
# @return [Boolean]
|
|
47
|
-
def ignored_chrome?(node) = @
|
|
61
|
+
def ignored_chrome?(node) = @scopes[node] == :chrome
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# Inside a utility landmark that is not chrome (aside, in-content nav, sectioned footer).
|
|
65
|
+
#
|
|
66
|
+
# @param node [Node]
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def peripheral?(node) = @scopes[node] == :peripheral
|
|
48
69
|
|
|
49
70
|
##
|
|
50
71
|
# Memoized default visible text (separator space, no excludes).
|
|
@@ -59,13 +80,11 @@ module Html2rss
|
|
|
59
80
|
|
|
60
81
|
##
|
|
61
82
|
# @param node [Node]
|
|
62
|
-
# @return [Integer]
|
|
63
|
-
def
|
|
64
|
-
return @
|
|
83
|
+
# @return [Integer] memoized letter/digit runs of the default visible text ({WordCount.runs})
|
|
84
|
+
def memo_run_count(node)
|
|
85
|
+
return @run_count[node] if @run_count.key?(node)
|
|
65
86
|
|
|
66
|
-
|
|
67
|
-
memo_visible_text(node).to_s.scan(/\p{Alnum}+/) { count += 1 }
|
|
68
|
-
@word_count[node] = count
|
|
87
|
+
@run_count[node] = WordCount.runs(memo_visible_text(node))
|
|
69
88
|
end
|
|
70
89
|
|
|
71
90
|
##
|
data/lib/html2rss/sst/node.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module SST
|
|
5
|
-
Node = Data.define(:name, :attrs, :own_text, :children, :tag_path)
|
|
5
|
+
Node = Data.define(:name, :attrs, :own_text, :tails, :children, :tag_path)
|
|
6
6
|
|
|
7
7
|
##
|
|
8
8
|
# Immutable simplified semantic tree node. Predicates and traversal live on
|
|
@@ -11,12 +11,13 @@ module Html2rss
|
|
|
11
11
|
##
|
|
12
12
|
# @param name [Symbol, String]
|
|
13
13
|
# @param attrs [Attrs, nil]
|
|
14
|
-
# @param own_text [String, nil]
|
|
14
|
+
# @param own_text [String, nil] all direct text, in document order
|
|
15
|
+
# @param tails [Array<String>, nil] direct text following each child, parallel to +children+
|
|
15
16
|
# @param children [Array<Node>, nil]
|
|
16
17
|
# @param tag_path [String, nil] index-free ancestry path for list clustering
|
|
17
18
|
# @return [Node]
|
|
18
|
-
# @raise [
|
|
19
|
-
def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil)
|
|
19
|
+
# @raise [InvalidError] on invalid name/attrs/children
|
|
20
|
+
def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil, tails: nil) # rubocop:disable Metrics/ParameterLists
|
|
20
21
|
attrs_value = coerce_attrs(attrs)
|
|
21
22
|
kids = coerce_children(children)
|
|
22
23
|
|
|
@@ -24,13 +25,14 @@ module Html2rss
|
|
|
24
25
|
name: coerce_name(name),
|
|
25
26
|
attrs: attrs_value,
|
|
26
27
|
own_text: own_text.to_s.freeze,
|
|
28
|
+
tails: Array(tails).map { _1.to_s.freeze }.freeze,
|
|
27
29
|
children: kids,
|
|
28
30
|
tag_path: tag_path.to_s.freeze
|
|
29
31
|
)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
def self.coerce_name(name)
|
|
33
|
-
raise
|
|
35
|
+
raise InvalidError, 'name is required' if name.nil? || name.to_s.strip.empty?
|
|
34
36
|
|
|
35
37
|
name.to_sym
|
|
36
38
|
end
|
|
@@ -38,7 +40,7 @@ module Html2rss
|
|
|
38
40
|
|
|
39
41
|
def self.coerce_attrs(attrs)
|
|
40
42
|
attrs_value = attrs.nil? ? Attrs.empty : attrs
|
|
41
|
-
raise
|
|
43
|
+
raise InvalidError, 'attrs must be SST::Attrs' unless attrs_value.is_a?(Attrs)
|
|
42
44
|
|
|
43
45
|
attrs_value
|
|
44
46
|
end
|
|
@@ -46,12 +48,16 @@ module Html2rss
|
|
|
46
48
|
|
|
47
49
|
def self.coerce_children(children)
|
|
48
50
|
kids = Array(children).freeze
|
|
49
|
-
raise
|
|
51
|
+
raise InvalidError, 'children must be SST::Node instances' unless kids.all?(Node)
|
|
50
52
|
|
|
51
53
|
kids
|
|
52
54
|
end
|
|
53
55
|
private_class_method :coerce_children
|
|
54
56
|
|
|
57
|
+
##
|
|
58
|
+
# @return [String] direct text before the first child
|
|
59
|
+
def lead_text = own_text.delete_suffix(tails.join)
|
|
60
|
+
|
|
55
61
|
##
|
|
56
62
|
# @return [Boolean]
|
|
57
63
|
def link?
|
|
@@ -68,11 +74,7 @@ module Html2rss
|
|
|
68
74
|
|
|
69
75
|
##
|
|
70
76
|
# @return [Boolean]
|
|
71
|
-
def utility_landmark? = Tags
|
|
72
|
-
|
|
73
|
-
##
|
|
74
|
-
# @return [Boolean]
|
|
75
|
-
def ignored_container_name? = Tags::IGNORED_CONTAINER_NAMES.include?(name)
|
|
77
|
+
def utility_landmark? = Tags.utility_landmark?(name.name, attrs.raw['role'])
|
|
76
78
|
|
|
77
79
|
##
|
|
78
80
|
# @return [Boolean]
|
|
@@ -117,15 +119,23 @@ module Html2rss
|
|
|
117
119
|
end
|
|
118
120
|
|
|
119
121
|
##
|
|
120
|
-
# @
|
|
122
|
+
# @yieldparam node [Node]
|
|
123
|
+
# @return [Array<Node>] self and descendants (depth-first) for which the block is truthy
|
|
121
124
|
def find_all(&)
|
|
122
|
-
|
|
125
|
+
collect_matching([], &)
|
|
123
126
|
end
|
|
124
127
|
|
|
125
128
|
##
|
|
126
|
-
# @
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
# @yieldparam node [Node]
|
|
130
|
+
# @return [Node, nil] first self-or-descendant (depth-first) for which the block is truthy
|
|
131
|
+
def find(&block)
|
|
132
|
+
return self if yield(self)
|
|
133
|
+
|
|
134
|
+
children.each do |child|
|
|
135
|
+
hit = child.find(&block)
|
|
136
|
+
return hit if hit
|
|
137
|
+
end
|
|
138
|
+
nil
|
|
129
139
|
end
|
|
130
140
|
|
|
131
141
|
##
|
|
@@ -143,23 +153,26 @@ module Html2rss
|
|
|
143
153
|
end
|
|
144
154
|
|
|
145
155
|
##
|
|
146
|
-
# @return [Integer]
|
|
147
|
-
def
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
count = 0
|
|
153
|
-
visible_text.to_s.scan(/\p{Alnum}+/) { count += 1 }
|
|
154
|
-
count
|
|
156
|
+
# @return [Integer] letter/digit runs of visible text ({WordCount.runs})
|
|
157
|
+
def run_count
|
|
158
|
+
index = Index.for_node(self)
|
|
159
|
+
index ? index.memo_run_count(self) : WordCount.runs(visible_text)
|
|
155
160
|
end
|
|
156
161
|
|
|
157
162
|
##
|
|
158
|
-
# @return [Float]
|
|
163
|
+
# @return [Float] letter/digit runs per descendant link
|
|
159
164
|
def text_density
|
|
160
|
-
|
|
165
|
+
runs = run_count
|
|
161
166
|
links = count_descendants(&:link?)
|
|
162
|
-
links.zero? ?
|
|
167
|
+
links.zero? ? runs.to_f : runs.to_f / links
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
protected
|
|
171
|
+
|
|
172
|
+
def collect_matching(found, &block)
|
|
173
|
+
found << self if yield(self)
|
|
174
|
+
children.each { |child| child.collect_matching(found, &block) }
|
|
175
|
+
found
|
|
163
176
|
end
|
|
164
177
|
end
|
|
165
178
|
end
|
|
@@ -8,13 +8,10 @@ module Html2rss
|
|
|
8
8
|
# Sole Nokogiri consumer on the heuristic auto-source path. Builds an
|
|
9
9
|
# immutable SST::Document with parent/depth/chrome indices.
|
|
10
10
|
class Normalizer # rubocop:disable Metrics/ClassLength
|
|
11
|
-
# Raised when no SST nodes survive normalization for the chosen root.
|
|
12
|
-
class EmptyTree < ArgumentError; end
|
|
13
|
-
|
|
14
11
|
# Hard ceiling for SST node allocations; beyond this we degrade.
|
|
15
12
|
MAX_NODES = 5_000
|
|
16
13
|
|
|
17
|
-
# Tags stripped entirely from the SST.
|
|
14
|
+
# Tags stripped entirely from the SST; a +noscript+ keeps only its file links (see {#child_nodes}).
|
|
18
15
|
STRIPPED_TAGS = Set['script', 'style', 'noscript', 'iframe', 'svg', 'template'].freeze
|
|
19
16
|
|
|
20
17
|
# Kept when MAX_NODES forces semantic-tag-only degrade.
|
|
@@ -32,9 +29,12 @@ module Html2rss
|
|
|
32
29
|
href |
|
|
33
30
|
src |
|
|
34
31
|
srcset |
|
|
32
|
+
poster\z |
|
|
35
33
|
style |
|
|
36
34
|
datetime |
|
|
37
35
|
itemprop |
|
|
36
|
+
rel\z |
|
|
37
|
+
hidden\z |
|
|
38
38
|
role |
|
|
39
39
|
type |
|
|
40
40
|
category |
|
|
@@ -57,17 +57,14 @@ module Html2rss
|
|
|
57
57
|
# Typed Attrs fields excluded from the leftover raw hash.
|
|
58
58
|
TYPED_ATTR_NAMES = Set['href', 'src', 'id', 'class', 'datetime', 'itemprop', 'style', 'srcset', 'type'].freeze
|
|
59
59
|
|
|
60
|
-
# String form of Tags::IGNORED_CONTAINER_NAMES for chrome checks without to_sym.
|
|
61
|
-
IGNORED_CONTAINER_TAGS = Tags::IGNORED_CONTAINER_NAMES.to_set(&:to_s).freeze
|
|
62
|
-
|
|
63
60
|
class << self
|
|
64
61
|
##
|
|
65
62
|
# @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
|
|
66
63
|
# HTML string or already-parsed node (String is parsed once here)
|
|
67
|
-
# @return [Document]
|
|
68
|
-
# @raise [
|
|
64
|
+
# @return [Document, nil] +nil+ when no SST nodes survive normalization
|
|
65
|
+
# @raise [InvalidError] when input is nil or unsupported
|
|
69
66
|
def call(input)
|
|
70
|
-
raise
|
|
67
|
+
raise InvalidError, 'input is required' if input.nil?
|
|
71
68
|
|
|
72
69
|
new(coerce(input)).call
|
|
73
70
|
end
|
|
@@ -81,7 +78,7 @@ module Html2rss
|
|
|
81
78
|
when String then Nokogiri::HTML(input)
|
|
82
79
|
when Nokogiri::XML::Node then input
|
|
83
80
|
else
|
|
84
|
-
raise
|
|
81
|
+
raise InvalidError, "expected String or Nokogiri::XML::Node, got #{input.class}"
|
|
85
82
|
end
|
|
86
83
|
end
|
|
87
84
|
end
|
|
@@ -93,17 +90,17 @@ module Html2rss
|
|
|
93
90
|
@degraded = false
|
|
94
91
|
@parents = {}.compare_by_identity
|
|
95
92
|
@depths = {}.compare_by_identity
|
|
96
|
-
@
|
|
93
|
+
@scopes = {}.compare_by_identity
|
|
97
94
|
end
|
|
98
95
|
|
|
99
96
|
##
|
|
100
|
-
# @return [Document]
|
|
97
|
+
# @return [Document, nil] +nil+ when no SST nodes survive normalization
|
|
101
98
|
def call
|
|
102
99
|
root_nk = resolve_root_nk
|
|
103
|
-
root = normalize_element(root_nk,
|
|
104
|
-
|
|
100
|
+
root = normalize_element(root_nk, depth: 0, path: '', scope: :page)
|
|
101
|
+
return unless root
|
|
105
102
|
|
|
106
|
-
index = Index.new(root:, parents: @parents, depths: @depths,
|
|
103
|
+
index = Index.new(root:, parents: @parents, depths: @depths, scopes: @scopes)
|
|
107
104
|
Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
|
|
108
105
|
end
|
|
109
106
|
|
|
@@ -149,16 +146,16 @@ module Html2rss
|
|
|
149
146
|
node.element? && !STRIPPED_TAGS.include?(Html2rss::Html::Probe.tag(node))
|
|
150
147
|
end
|
|
151
148
|
|
|
152
|
-
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
153
|
-
def normalize_element(nk_node,
|
|
149
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
150
|
+
def normalize_element(nk_node, depth:, path:, scope:)
|
|
154
151
|
return unless nk_node.element?
|
|
155
152
|
|
|
156
|
-
tag =
|
|
157
|
-
return
|
|
153
|
+
tag = Html::Probe.tag(nk_node)
|
|
154
|
+
return unless (nk_children = child_nodes(nk_node, tag))
|
|
158
155
|
|
|
159
156
|
if @node_count >= MAX_NODES && !@degraded
|
|
160
157
|
@degraded = true
|
|
161
|
-
|
|
158
|
+
Log.warn({
|
|
162
159
|
event_name: 'sst.normalizer.max_nodes',
|
|
163
160
|
max_nodes: MAX_NODES,
|
|
164
161
|
message: "sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only"
|
|
@@ -168,26 +165,32 @@ module Html2rss
|
|
|
168
165
|
return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
|
|
169
166
|
|
|
170
167
|
@node_count += 1
|
|
171
|
-
tag_path =
|
|
168
|
+
tag_path = "#{path}/#{tag}"
|
|
172
169
|
attrs = extract_attrs(nk_node)
|
|
173
|
-
|
|
174
|
-
chrome_here = chrome || IGNORED_CONTAINER_TAGS.include?(tag)
|
|
170
|
+
scope_here = Tags.chrome_scope(nk_node, scope)
|
|
175
171
|
|
|
176
|
-
children =
|
|
177
|
-
normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
|
|
178
|
-
end.freeze
|
|
172
|
+
children, lead, tails = normalize_children(nk_children, depth:, path: tag_path, scope: scope_here)
|
|
179
173
|
|
|
180
|
-
node = Node.build(name: tag, attrs:, own_text
|
|
181
|
-
@parents[node] = parent == :pending ? nil : parent
|
|
174
|
+
node = Node.build(name: tag, attrs:, own_text: [lead, *tails].join, children:, tag_path:, tails:)
|
|
182
175
|
@depths[node] = depth
|
|
183
|
-
@
|
|
184
|
-
|
|
185
|
-
# Fix parent pointers now that node exists.
|
|
176
|
+
@scopes[node] = scope_here
|
|
186
177
|
children.each { |child| @parents[child] = node }
|
|
187
178
|
|
|
188
179
|
node
|
|
189
180
|
end
|
|
190
181
|
|
|
182
|
+
# The Nokogiri children to normalize, or nil for a stripped tag. A +noscript+ is kept only for the
|
|
183
|
+
# file links inside it ({Html::ArticleRules::Enclosure.file_link?}), as its only children, so a
|
|
184
|
+
# card's no-script download link still names its enclosure; its text stays invisible
|
|
185
|
+
# ({Tags::INVISIBLE_NAMES}) and a +noscript+ without one is stripped.
|
|
186
|
+
def child_nodes(nk_node, tag)
|
|
187
|
+
return nk_node.children unless STRIPPED_TAGS.include?(tag)
|
|
188
|
+
return unless tag == 'noscript'
|
|
189
|
+
|
|
190
|
+
links = nk_node.xpath('.//a[@href]').select { Html::ArticleRules::Enclosure.file_link?(_1['href']) }
|
|
191
|
+
links unless links.empty?
|
|
192
|
+
end
|
|
193
|
+
|
|
191
194
|
def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
|
|
192
195
|
Attrs.build(
|
|
193
196
|
href: nk_node['href'],
|
|
@@ -213,8 +216,18 @@ module Html2rss
|
|
|
213
216
|
end
|
|
214
217
|
end
|
|
215
218
|
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
# Walks child nodes once, keeping direct text in the slot of the preceding kept child
|
|
220
|
+
# (slot 0 is the lead text) so text order survives the element-only tree.
|
|
221
|
+
def normalize_children(nk_children, depth:, path:, scope:)
|
|
222
|
+
children = []
|
|
223
|
+
slots = [+'']
|
|
224
|
+
nk_children.each do |child|
|
|
225
|
+
next slots.last << child.text if child.text?
|
|
226
|
+
|
|
227
|
+
node = normalize_element(child, depth: depth + 1, path:, scope:)
|
|
228
|
+
(children << node) && (slots << +'') if node
|
|
229
|
+
end
|
|
230
|
+
[children, slots.first, slots.drop(1)]
|
|
218
231
|
end
|
|
219
232
|
end
|
|
220
233
|
end
|
data/lib/html2rss/sst/tags.rb
CHANGED
|
@@ -11,16 +11,127 @@ module Html2rss
|
|
|
11
11
|
BLOCK_NAMES = Set[:p, :div, :li, :ul, :ol, :h1, :h2, :h3, :h4, :h5, :h6, :tr, :br].freeze
|
|
12
12
|
# Names skipped when collecting visible text.
|
|
13
13
|
INVISIBLE_NAMES = Set[:svg, :script, :noscript, :style, :template].freeze
|
|
14
|
-
#
|
|
14
|
+
# Utility landmark names: peripheral unless chrome (see {.chrome_scope}).
|
|
15
15
|
UTILITY_LANDMARK_NAMES = Set[:nav, :aside, :footer, :menu].freeze
|
|
16
16
|
# Container names ignored as article roots.
|
|
17
|
-
IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :svg, :script, :style].freeze
|
|
17
|
+
IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :dialog, :svg, :script, :style].freeze
|
|
18
|
+
# Ignored containers that are chrome only at page level (ARIA banner/contentinfo scoping).
|
|
19
|
+
PAGE_CHROME_NAMES = Set[:header, :footer].freeze
|
|
20
|
+
# Ancestors that scope a header/footer to their own section instead of the page.
|
|
21
|
+
SECTIONING_NAMES = Set[:article, :section, :aside, :main].freeze
|
|
18
22
|
# Names excluded from class/structure clustering.
|
|
19
23
|
CLUSTER_EXCLUDED_NAMES = Set[:html, :body, :nav, :footer, :header, :svg, :script, :style].freeze
|
|
20
24
|
# Href prefixes that never count as content destinations.
|
|
21
25
|
SKIP_HREF_PREFIXES = ['#', 'javascript:', 'mailto:', 'tel:', 'file://', 'sms:', 'data:'].freeze
|
|
22
26
|
# Layout-ish names used when resolving nested wrapper groups.
|
|
23
27
|
LAYOUT_NAMES = Set[:div, :section, :article, :li, :ul, :ol].freeze
|
|
28
|
+
|
|
29
|
+
# header/footer/nav: chrome at page level, their parent's scope inside sectioning content.
|
|
30
|
+
PAGE_CHROME = PAGE_CHROME_NAMES.to_set(&:to_s).freeze
|
|
31
|
+
# +noscript+ too: the SST keeps one only for its file links (enclosures), never as content anchors.
|
|
32
|
+
ALWAYS_CHROME = ((IGNORED_CONTAINER_NAMES.to_set(&:to_s) - PAGE_CHROME - Set['nav']) | Set['noscript']).freeze
|
|
33
|
+
UTILITY_LANDMARK = UTILITY_LANDMARK_NAMES.to_set(&:to_s).freeze
|
|
34
|
+
SECTIONING = SECTIONING_NAMES.to_set(&:to_s).freeze
|
|
35
|
+
# ARIA roles whose subtree is chrome anywhere (+search+ is not: it may wrap the result list).
|
|
36
|
+
CHROME_ROLES = Set['navigation', 'menu', 'menubar', 'dialog', 'alertdialog'].freeze
|
|
37
|
+
# ARIA landmark roles that, like header/footer, are chrome only at page level.
|
|
38
|
+
PAGE_CHROME_ROLES = Set['banner', 'contentinfo'].freeze
|
|
39
|
+
# Utility landmark roles: the chrome roles plus +complementary+ (the aside role).
|
|
40
|
+
UTILITY_ROLES = (CHROME_ROLES | Set['complementary']).freeze
|
|
41
|
+
# A nav or list labelled (aria-label) or classed as pagination is chrome in any scope.
|
|
42
|
+
PAGER_NAMES = Set['nav', 'ul', 'ol'].freeze
|
|
43
|
+
# Pagination nouns ("Results pagination", "paginación"), never +paginated+.
|
|
44
|
+
PAGER_LABEL = /\bpagina(?:tion|tie|ción|cion|zione|cja|ção|cao)(?!\p{L})|\bpaginering\b|\bpaginierung\b|
|
|
45
|
+
\bpager\b|\bpaging\b|\bpage\snavigation\b/ix
|
|
46
|
+
# nav: a class-token part (+pagination+, +gsc-pagination+, +pager__list+), not +paginated-list+.
|
|
47
|
+
PAGER_CLASS = /(?:\A|\s|[-_])(?:pagination|pager|paging)(?:\z|\s|[-_])/i
|
|
48
|
+
# ul/ol: only a whole class token (+infinite-pager+ or +js-pagination-container+ is a feed list).
|
|
49
|
+
PAGER_TOKENS = Set['pagination', 'pager', 'paging'].freeze
|
|
50
|
+
PAGER_RELS = Set['next', 'prev', 'previous'].freeze
|
|
51
|
+
# A nav linking rel=next/prev is a pager.
|
|
52
|
+
PAGER_LINK_XPATH = PAGER_RELS.map { "contains(concat(' ', normalize-space(@rel), ' '), ' #{_1} ')" }
|
|
53
|
+
.join(' or ').then { ".//a[#{_1}]" }.freeze
|
|
54
|
+
# Page-navigation link shapes: a page number, an arrow, prev/next wording, or a page query/path.
|
|
55
|
+
PAGE_LINK_TEXT = /\A(?:\d{1,4}|[«‹›»←→<>]+|\W*(?:prev(?:ious)?|next|older|newer|first|last)\b.*)\z/im
|
|
56
|
+
PAGE_LINK_HREF = %r{[?&](?:page|paged|pg|p)=\d+|/page/\d+}i
|
|
57
|
+
private_constant :PAGE_CHROME, :ALWAYS_CHROME, :UTILITY_LANDMARK, :SECTIONING, :CHROME_ROLES,
|
|
58
|
+
:PAGE_CHROME_ROLES, :UTILITY_ROLES, :PAGER_NAMES, :PAGER_LABEL, :PAGER_CLASS,
|
|
59
|
+
:PAGER_TOKENS, :PAGER_RELS, :PAGER_LINK_XPATH, :PAGE_LINK_TEXT, :PAGE_LINK_HREF
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# The single chrome-scope rule, folded down the tree by both the SST normalizer
|
|
63
|
+
# and the Nokogiri ancestor walk. +:chrome+ sticks: dialog, noscript and the ARIA navigation,
|
|
64
|
+
# menu and dialog roles are chrome; header/footer/nav and the banner/contentinfo roles
|
|
65
|
+
# are chrome only at +:page+ level; a pager (a nav labelled or classed as pagination or
|
|
66
|
+
# linking rel=next/prev, or a list labelled or whole-token classed as pagination whose
|
|
67
|
+
# links mostly look like page navigation) is chrome in any scope. Otherwise a
|
|
68
|
+
# utility landmark ({.utility_landmark?}) opens a sticky +:peripheral+ scope, and
|
|
69
|
+
# article/section/main (or role=main) open +:sectioned+, so card headers and in-content
|
|
70
|
+
# nav lists stay out of chrome.
|
|
71
|
+
#
|
|
72
|
+
# @param element [Nokogiri::XML::Element] element being entered
|
|
73
|
+
# @param scope [Symbol] parent scope: +:page+, +:sectioned+, +:peripheral+, or +:chrome+
|
|
74
|
+
# @return [Symbol] scope the element's subtree inherits
|
|
75
|
+
def self.chrome_scope(element, scope)
|
|
76
|
+
tag = Html::Probe.tag(element)
|
|
77
|
+
roles = role_tokens(element['role'])
|
|
78
|
+
return :chrome if scope == :chrome || chrome?(element, tag, roles, scope)
|
|
79
|
+
return :peripheral if scope == :peripheral || utility?(tag, roles)
|
|
80
|
+
|
|
81
|
+
SECTIONING.include?(tag) || roles.include?('main') ? :sectioned : scope
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# Utility landmark: nav/aside/footer/menu, or a navigation, menu, dialog or
|
|
86
|
+
# complementary role. Peripheral content when not chrome; never an article card.
|
|
87
|
+
#
|
|
88
|
+
# @param tag [String] folded tag name
|
|
89
|
+
# @param role [String, nil] raw +role+ attribute
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def self.utility_landmark?(tag, role) = utility?(tag, role_tokens(role))
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Chrome only at page level: header/footer, or the banner/contentinfo role.
|
|
95
|
+
#
|
|
96
|
+
# @param tag [String] folded tag name
|
|
97
|
+
# @param role [String, nil] raw +role+ attribute
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def self.page_level_chrome?(tag, role) = page_level?(tag, role_tokens(role))
|
|
100
|
+
|
|
101
|
+
def self.role_tokens(role) = role ? role.downcase.split : []
|
|
102
|
+
|
|
103
|
+
def self.utility?(tag, roles) = UTILITY_LANDMARK.include?(tag) || roles.any? { UTILITY_ROLES.include?(_1) }
|
|
104
|
+
|
|
105
|
+
def self.page_level?(tag, roles) = PAGE_CHROME.include?(tag) || roles.any? { PAGE_CHROME_ROLES.include?(_1) }
|
|
106
|
+
|
|
107
|
+
def self.chrome?(element, tag, roles, scope)
|
|
108
|
+
return true if ALWAYS_CHROME.include?(tag) || roles.any? { CHROME_ROLES.include?(_1) }
|
|
109
|
+
|
|
110
|
+
(scope == :page && (tag == 'nav' || page_level?(tag, roles))) || pager?(element, tag)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.pager?(element, tag)
|
|
114
|
+
return false unless PAGER_NAMES.include?(tag)
|
|
115
|
+
return list_pager?(element) unless tag == 'nav'
|
|
116
|
+
return true if element['aria-label'].to_s.match?(PAGER_LABEL) || element['class'].to_s.match?(PAGER_CLASS)
|
|
117
|
+
|
|
118
|
+
!element.at_xpath(PAGER_LINK_XPATH).nil?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A list is a pager only when labelled or whole-token classed as one and at least
|
|
122
|
+
# half of its links look like page navigation.
|
|
123
|
+
def self.list_pager?(element)
|
|
124
|
+
named = element['aria-label'].to_s.match?(PAGER_LABEL) ||
|
|
125
|
+
element['class'].to_s.downcase.split.any? { PAGER_TOKENS.include?(_1) }
|
|
126
|
+
links = named ? element.css('a') : []
|
|
127
|
+
!links.empty? && links.count { page_link?(_1) } * 2 >= links.size
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.page_link?(link)
|
|
131
|
+
link['rel'].to_s.downcase.split.any? { PAGER_RELS.include?(_1) } ||
|
|
132
|
+
link['href'].to_s.match?(PAGE_LINK_HREF) || link.text.strip.match?(PAGE_LINK_TEXT)
|
|
133
|
+
end
|
|
134
|
+
private_class_method :role_tokens, :utility?, :page_level?, :chrome?, :pager?, :list_pager?, :page_link?
|
|
24
135
|
end
|
|
25
136
|
end
|
|
26
137
|
end
|