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
|
@@ -44,7 +44,7 @@ module Html2rss
|
|
|
44
44
|
# @param _context [Selectors::StepEnv] post-processor context
|
|
45
45
|
# @return [void]
|
|
46
46
|
def self.validate_args!(value, _context)
|
|
47
|
-
raise
|
|
47
|
+
raise MissingOption, 'The `value` option is missing or empty.' if value.to_s.empty?
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
##
|
|
@@ -17,12 +17,14 @@ module Html2rss
|
|
|
17
17
|
# - `referrer-policy='no-referrer'` to <img> tags
|
|
18
18
|
# - wraps all <img> tags, whose direct parent is not an <a>, into an <a>
|
|
19
19
|
# linking to the <img>'s `src`.
|
|
20
|
+
# - keeps <audio>, <video> and <source> with an absolute http(s) `src`; a player
|
|
21
|
+
# without a playable `src` (its own or a <source>'s) is removed.
|
|
20
22
|
#
|
|
21
23
|
# Imagine this HTML structure:
|
|
22
24
|
#
|
|
23
25
|
# <section>
|
|
24
26
|
# Lorem <b>ipsum</b> dolor...
|
|
25
|
-
# <iframe src="https://evil.
|
|
27
|
+
# <iframe src="https://evil.example/miner"></iframe>
|
|
26
28
|
# <script>alert();</script>
|
|
27
29
|
# </section>
|
|
28
30
|
#
|
|
@@ -93,10 +95,20 @@ module Html2rss
|
|
|
93
95
|
'audio' => {
|
|
94
96
|
'referrerpolicy' => 'no-referrer',
|
|
95
97
|
'crossorigin' => 'anonymous',
|
|
96
|
-
'preload' => 'none'
|
|
98
|
+
'preload' => 'none',
|
|
99
|
+
'controls' => 'true'
|
|
97
100
|
}
|
|
98
101
|
}.freeze
|
|
99
102
|
|
|
103
|
+
# Media elements kept in descriptions and the attributes they keep besides the html2rss defaults
|
|
104
|
+
# ({TAG_ATTRIBUTES}); {HtmlTransformers::KeepPlayableMedia} keeps +src+ only as an absolute
|
|
105
|
+
# +http(s)+ URL and drops a player left without one.
|
|
106
|
+
MEDIA_ATTRIBUTES = {
|
|
107
|
+
'audio' => %w[src],
|
|
108
|
+
'video' => %w[src],
|
|
109
|
+
'source' => %w[src type]
|
|
110
|
+
}.freeze
|
|
111
|
+
|
|
100
112
|
# @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
|
|
101
113
|
def self.schema_export = SchemaExport.for_post_processor(name: :sanitize_html, klass: self)
|
|
102
114
|
|
|
@@ -145,24 +157,25 @@ module Html2rss
|
|
|
145
157
|
##
|
|
146
158
|
# @param base_url [String, Html2rss::Url]
|
|
147
159
|
# @return [Hash] the memoized sanitize configuration
|
|
148
|
-
# rubocop:disable-next Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
|
|
160
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, ThreadSafety/ClassInstanceVariable
|
|
149
161
|
def self.sanitize_config(base_url)
|
|
150
162
|
@sanitize_configs ||= {}
|
|
151
163
|
@sanitize_configs[base_url] ||= begin
|
|
152
164
|
config = Sanitize::Config.merge(
|
|
153
165
|
Sanitize::Config::RELAXED,
|
|
154
|
-
attributes: { all: %w[dir lang alt title translate] },
|
|
166
|
+
attributes: { all: %w[dir lang alt title translate], **MEDIA_ATTRIBUTES },
|
|
155
167
|
add_attributes: TAG_ATTRIBUTES,
|
|
156
168
|
transformers: [
|
|
157
169
|
lambda { |env|
|
|
158
170
|
HtmlTransformers::TransformUrlsToAbsoluteOnes.new(base_url).call(**env)
|
|
159
171
|
},
|
|
172
|
+
->(env) { HtmlTransformers::KeepPlayableMedia.new(base_url).call(**env) },
|
|
160
173
|
->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
|
|
161
174
|
]
|
|
162
175
|
)
|
|
163
176
|
config[:elements].delete('style')
|
|
164
177
|
config.delete(:css)
|
|
165
|
-
config[:elements].push(
|
|
178
|
+
config[:elements].push(*MEDIA_ATTRIBUTES.keys)
|
|
166
179
|
config.freeze
|
|
167
180
|
end
|
|
168
181
|
end
|
|
@@ -70,7 +70,7 @@ module Html2rss
|
|
|
70
70
|
return (start..) unless step_config.key?(:end)
|
|
71
71
|
|
|
72
72
|
finish = step_config[:end]
|
|
73
|
-
raise
|
|
73
|
+
raise InvalidOption, 'The `start` value must be unequal to the `end` value.' if start == finish
|
|
74
74
|
|
|
75
75
|
(start..finish)
|
|
76
76
|
end
|
data/lib/html2rss/selectors.rb
CHANGED
|
@@ -35,6 +35,10 @@ module Html2rss
|
|
|
35
35
|
# Config selector keys that map onto a different {Article} attribute.
|
|
36
36
|
# +:enclosure+ stays singular in YAML; Article stores +:enclosures+.
|
|
37
37
|
SELECTOR_TO_ARTICLE_KEY = { enclosure: :enclosures }.freeze
|
|
38
|
+
# Admission-drop reason tallied for selector items that carry no URL (same shape as
|
|
39
|
+
# {AutoSource#admission_drops}).
|
|
40
|
+
MISSING_URL_REASON = 'missing_url'
|
|
41
|
+
|
|
38
42
|
# Selector keys that may be copied onto an Article (PROVIDED_KEYS + mapped aliases).
|
|
39
43
|
SELECTABLE_SELECTOR_KEYS = Set[*(Html2rss::Article::PROVIDED_KEYS + SELECTOR_TO_ARTICLE_KEY.keys)].freeze
|
|
40
44
|
# ArticleExtractor keys that enhance can fill. Skip the extractor when all are already present.
|
|
@@ -56,6 +60,7 @@ module Html2rss
|
|
|
56
60
|
@time_zone = time_zone
|
|
57
61
|
@rss_item_attributes = @selectors.keys.select { SELECTABLE_SELECTOR_KEYS.include?(_1) }
|
|
58
62
|
@parsed_body = nil
|
|
63
|
+
@unlinked_count = 0
|
|
59
64
|
@compiled_fields = compile_selector_fields
|
|
60
65
|
@compiled_item_fields = @rss_item_attributes.filter_map { @compiled_fields[_1] }
|
|
61
66
|
end
|
|
@@ -70,39 +75,52 @@ module Html2rss
|
|
|
70
75
|
end
|
|
71
76
|
|
|
72
77
|
##
|
|
73
|
-
#
|
|
78
|
+
# Drop tallies for the last iteration: items skipped for having no URL, as {AutoSource#admission_drops}.
|
|
79
|
+
#
|
|
80
|
+
# @return [Hash{String => Integer}] empty when every item was linked
|
|
81
|
+
def admission_drops
|
|
82
|
+
@unlinked_count.positive? ? { MISSING_URL_REASON => @unlinked_count }.freeze : {}.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
# Iterates over each scraped article. An item without a URL is skipped and tallied in {#admission_drops},
|
|
87
|
+
# as in auto-source ({Html2rss::Article#linked?}).
|
|
74
88
|
#
|
|
75
89
|
# @yield [article] Gives each article as an Html2rss::Article.
|
|
76
90
|
# @return [Enumerator] An enumerator if no block is given.
|
|
77
91
|
def each(&)
|
|
78
|
-
return enum_for
|
|
92
|
+
return enum_for unless block_given?
|
|
79
93
|
|
|
80
94
|
enhance = enhance?
|
|
95
|
+
@unlinked_count = 0
|
|
81
96
|
|
|
82
97
|
parsed_body.css(items_selector).each do |item|
|
|
83
98
|
article_hash = extract_article(item)
|
|
84
99
|
|
|
85
100
|
enhance_article_hash(article_hash, item) if enhance
|
|
86
101
|
|
|
87
|
-
|
|
102
|
+
article = build_article(article_hash)
|
|
103
|
+
article.linked? ? yield(article) : @unlinked_count += 1
|
|
88
104
|
end
|
|
89
105
|
end
|
|
90
106
|
|
|
91
107
|
##
|
|
92
108
|
# Yields +baseline+, +enhanced+, and the item node per list entry for enhance diagnostics.
|
|
109
|
+
#
|
|
110
|
+
# @param linked_only [Boolean] skip items {#each} would drop for having no URL
|
|
93
111
|
# Consumed by {Test::EnhanceAudit} so the extraction loop has one owner.
|
|
94
112
|
#
|
|
95
113
|
# @yieldparam baseline [Hash]
|
|
96
114
|
# @yieldparam enhanced [Hash]
|
|
97
115
|
# @yieldparam item [Nokogiri::XML::Element]
|
|
98
116
|
# @return [Enumerator] if no block is given
|
|
99
|
-
def each_enhance_pair
|
|
100
|
-
return enum_for(:each_enhance_pair) unless block_given?
|
|
117
|
+
def each_enhance_pair(linked_only: false)
|
|
118
|
+
return enum_for(:each_enhance_pair, linked_only:) unless block_given?
|
|
101
119
|
|
|
102
120
|
parsed_body.css(items_selector).each do |item|
|
|
103
121
|
baseline = extract_article(item)
|
|
104
122
|
enhanced = enhance_article_hash(baseline.dup, item)
|
|
105
|
-
yield baseline, enhanced, item
|
|
123
|
+
yield baseline, enhanced, item unless linked_only && !build_article(enhanced).linked?
|
|
106
124
|
end
|
|
107
125
|
end
|
|
108
126
|
|
|
@@ -168,6 +186,12 @@ module Html2rss
|
|
|
168
186
|
text.empty? || text == DEFAULT_ITEMS_SELECTOR
|
|
169
187
|
end
|
|
170
188
|
|
|
189
|
+
##
|
|
190
|
+
# Whether +selectors.items.enhance+ is on: each matched item node fills fields that explicit
|
|
191
|
+
# selectors left empty (see {ENHANCEABLE_KEYS}) via {Html::ArticleExtractor}, using only the
|
|
192
|
+
# item's own markup and making no extra requests.
|
|
193
|
+
#
|
|
194
|
+
# @return [Boolean]
|
|
171
195
|
def enhance? = !!@selectors.dig(ITEMS_SELECTOR_KEY, :enhance)
|
|
172
196
|
|
|
173
197
|
def parsed_body
|
|
@@ -183,6 +207,8 @@ module Html2rss
|
|
|
183
207
|
end
|
|
184
208
|
end
|
|
185
209
|
|
|
210
|
+
def build_article(article_hash) = Article.new(**article_hash, scraper: self.class)
|
|
211
|
+
|
|
186
212
|
def extract_article(item)
|
|
187
213
|
scope = item_env_for(item, @url)
|
|
188
214
|
hash = @compiled_item_fields.each_with_object({}) do |field, h|
|
|
@@ -190,13 +216,30 @@ module Html2rss
|
|
|
190
216
|
next if value.nil?
|
|
191
217
|
|
|
192
218
|
article_key = field.article_key
|
|
193
|
-
h[article_key] = article_key
|
|
219
|
+
h[article_key] = article_value(article_key, value, scope)
|
|
194
220
|
end
|
|
195
221
|
|
|
196
222
|
hash[:url] ||= default_item_url(item) if anchor_element?(item)
|
|
197
223
|
hash
|
|
198
224
|
end
|
|
199
225
|
|
|
226
|
+
def article_value(article_key, value, scope)
|
|
227
|
+
case article_key
|
|
228
|
+
when :enclosures then wrap_enclosure_value(value)
|
|
229
|
+
when :url then resolve_url_value(value, scope.base_url)
|
|
230
|
+
else value
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# A +url+ selector value that is a single whitespace-free string token (a +static+ value or a post-processor
|
|
235
|
+
# result) resolves against the page URL like an +href+; a value that is no http(s) reference stays as given.
|
|
236
|
+
# Text with surrounding words is left to +Article#url+'s sanitizer, which extracts the embedded URL.
|
|
237
|
+
def resolve_url_value(value, base_url)
|
|
238
|
+
return value unless value.is_a?(String) && value.match?(/\A\S+\z/)
|
|
239
|
+
|
|
240
|
+
Url.resolve_web(value, base_url) || value
|
|
241
|
+
end
|
|
242
|
+
|
|
200
243
|
def enhance_article_hash(article_hash, article_tag) # rubocop:disable Metrics/MethodLength -- merge extracted keys
|
|
201
244
|
return article_hash if enhanceable_fields_present?(article_hash)
|
|
202
245
|
|
|
@@ -205,7 +248,6 @@ module Html2rss
|
|
|
205
248
|
article_tag,
|
|
206
249
|
base_url: @url,
|
|
207
250
|
selected_anchor:,
|
|
208
|
-
fallback_anchorless: true,
|
|
209
251
|
time_zone: @time_zone
|
|
210
252
|
)
|
|
211
253
|
return article_hash unless extracted
|
|
@@ -392,12 +434,11 @@ module Html2rss
|
|
|
392
434
|
Extractors.element(item, config[:selector])
|
|
393
435
|
end
|
|
394
436
|
|
|
395
|
-
# @return [Hash, nil] enclosure details, or nil when the selector yields nothing
|
|
437
|
+
# @return [Hash, nil] enclosure details, or nil when the selector yields nothing or no http(s) URL
|
|
438
|
+
# ({Url.resolve_web})
|
|
396
439
|
def enclosure(scope:, config:)
|
|
397
440
|
selected = select_regular(:enclosure, scope:, config:)
|
|
398
|
-
return
|
|
399
|
-
|
|
400
|
-
url = Url.from_relative(selected, scope.base_url)
|
|
441
|
+
return unless (url = Url.resolve_web(selected, scope.base_url))
|
|
401
442
|
|
|
402
443
|
{ url:, type: config[:content_type] }
|
|
403
444
|
end
|
data/lib/html2rss/sst/attrs.rb
CHANGED
|
@@ -65,7 +65,7 @@ module Html2rss
|
|
|
65
65
|
case class_names
|
|
66
66
|
when nil then AttrDefaults::EMPTY_CLASS_NAMES
|
|
67
67
|
when Array then class_names.map { |token| token.to_s.freeze }.freeze
|
|
68
|
-
else raise
|
|
68
|
+
else raise InvalidError, "class_names must be an Array, got #{class_names.class}"
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
@@ -73,7 +73,7 @@ module Html2rss
|
|
|
73
73
|
case raw
|
|
74
74
|
when nil then AttrDefaults::EMPTY_RAW
|
|
75
75
|
when Hash then raw.transform_values { |v| v.to_s.freeze }.freeze
|
|
76
|
-
else raise
|
|
76
|
+
else raise InvalidError, "raw must be a Hash, got #{raw.class}"
|
|
77
77
|
end
|
|
78
78
|
end
|
|
79
79
|
end
|
|
@@ -13,10 +13,10 @@ module Html2rss
|
|
|
13
13
|
# @param degraded [Boolean] true when MAX_NODES forced semantic-tag-only mode
|
|
14
14
|
# @param node_count [Integer]
|
|
15
15
|
# @return [Document]
|
|
16
|
-
# @raise [
|
|
16
|
+
# @raise [InvalidError] when root/index missing
|
|
17
17
|
def self.build(root:, index:, degraded: false, node_count: 0)
|
|
18
|
-
raise
|
|
19
|
-
raise
|
|
18
|
+
raise InvalidError, 'root is required' unless root.is_a?(Node)
|
|
19
|
+
raise InvalidError, 'index is required' unless index.is_a?(Index)
|
|
20
20
|
|
|
21
21
|
new(root:, index:, degraded: !!degraded, node_count: Integer(node_count))
|
|
22
22
|
end
|
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
|
@@ -16,7 +16,7 @@ module Html2rss
|
|
|
16
16
|
# @param children [Array<Node>, nil]
|
|
17
17
|
# @param tag_path [String, nil] index-free ancestry path for list clustering
|
|
18
18
|
# @return [Node]
|
|
19
|
-
# @raise [
|
|
19
|
+
# @raise [InvalidError] on invalid name/attrs/children
|
|
20
20
|
def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil, tails: nil) # rubocop:disable Metrics/ParameterLists
|
|
21
21
|
attrs_value = coerce_attrs(attrs)
|
|
22
22
|
kids = coerce_children(children)
|
|
@@ -32,7 +32,7 @@ module Html2rss
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def self.coerce_name(name)
|
|
35
|
-
raise
|
|
35
|
+
raise InvalidError, 'name is required' if name.nil? || name.to_s.strip.empty?
|
|
36
36
|
|
|
37
37
|
name.to_sym
|
|
38
38
|
end
|
|
@@ -40,7 +40,7 @@ module Html2rss
|
|
|
40
40
|
|
|
41
41
|
def self.coerce_attrs(attrs)
|
|
42
42
|
attrs_value = attrs.nil? ? Attrs.empty : attrs
|
|
43
|
-
raise
|
|
43
|
+
raise InvalidError, 'attrs must be SST::Attrs' unless attrs_value.is_a?(Attrs)
|
|
44
44
|
|
|
45
45
|
attrs_value
|
|
46
46
|
end
|
|
@@ -48,7 +48,7 @@ module Html2rss
|
|
|
48
48
|
|
|
49
49
|
def self.coerce_children(children)
|
|
50
50
|
kids = Array(children).freeze
|
|
51
|
-
raise
|
|
51
|
+
raise InvalidError, 'children must be SST::Node instances' unless kids.all?(Node)
|
|
52
52
|
|
|
53
53
|
kids
|
|
54
54
|
end
|
|
@@ -74,11 +74,7 @@ module Html2rss
|
|
|
74
74
|
|
|
75
75
|
##
|
|
76
76
|
# @return [Boolean]
|
|
77
|
-
def utility_landmark? = Tags
|
|
78
|
-
|
|
79
|
-
##
|
|
80
|
-
# @return [Boolean]
|
|
81
|
-
def ignored_container_name? = Tags::IGNORED_CONTAINER_NAMES.include?(name)
|
|
77
|
+
def utility_landmark? = Tags.utility_landmark?(name.name, attrs.raw['role'])
|
|
82
78
|
|
|
83
79
|
##
|
|
84
80
|
# @return [Boolean]
|
|
@@ -123,15 +119,23 @@ module Html2rss
|
|
|
123
119
|
end
|
|
124
120
|
|
|
125
121
|
##
|
|
126
|
-
# @
|
|
122
|
+
# @yieldparam node [Node]
|
|
123
|
+
# @return [Array<Node>] self and descendants (depth-first) for which the block is truthy
|
|
127
124
|
def find_all(&)
|
|
128
|
-
|
|
125
|
+
collect_matching([], &)
|
|
129
126
|
end
|
|
130
127
|
|
|
131
128
|
##
|
|
132
|
-
# @
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
135
139
|
end
|
|
136
140
|
|
|
137
141
|
##
|
|
@@ -149,23 +153,26 @@ module Html2rss
|
|
|
149
153
|
end
|
|
150
154
|
|
|
151
155
|
##
|
|
152
|
-
# @return [Integer]
|
|
153
|
-
def
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
count = 0
|
|
159
|
-
visible_text.to_s.scan(/\p{Alnum}+/) { count += 1 }
|
|
160
|
-
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)
|
|
161
160
|
end
|
|
162
161
|
|
|
163
162
|
##
|
|
164
|
-
# @return [Float]
|
|
163
|
+
# @return [Float] letter/digit runs per descendant link
|
|
165
164
|
def text_density
|
|
166
|
-
|
|
165
|
+
runs = run_count
|
|
167
166
|
links = count_descendants(&:link?)
|
|
168
|
-
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
|
|
169
176
|
end
|
|
170
177
|
end
|
|
171
178
|
end
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# Hard ceiling for SST node allocations; beyond this we degrade.
|
|
12
12
|
MAX_NODES = 5_000
|
|
13
13
|
|
|
14
|
-
# Tags stripped entirely from the SST.
|
|
14
|
+
# Tags stripped entirely from the SST; a +noscript+ keeps only its file links (see {#child_nodes}).
|
|
15
15
|
STRIPPED_TAGS = Set['script', 'style', 'noscript', 'iframe', 'svg', 'template'].freeze
|
|
16
16
|
|
|
17
17
|
# Kept when MAX_NODES forces semantic-tag-only degrade.
|
|
@@ -29,9 +29,12 @@ module Html2rss
|
|
|
29
29
|
href |
|
|
30
30
|
src |
|
|
31
31
|
srcset |
|
|
32
|
+
poster\z |
|
|
32
33
|
style |
|
|
33
34
|
datetime |
|
|
34
35
|
itemprop |
|
|
36
|
+
rel\z |
|
|
37
|
+
hidden\z |
|
|
35
38
|
role |
|
|
36
39
|
type |
|
|
37
40
|
category |
|
|
@@ -54,17 +57,14 @@ module Html2rss
|
|
|
54
57
|
# Typed Attrs fields excluded from the leftover raw hash.
|
|
55
58
|
TYPED_ATTR_NAMES = Set['href', 'src', 'id', 'class', 'datetime', 'itemprop', 'style', 'srcset', 'type'].freeze
|
|
56
59
|
|
|
57
|
-
# String form of Tags::IGNORED_CONTAINER_NAMES for chrome checks without to_sym.
|
|
58
|
-
IGNORED_CONTAINER_TAGS = Tags::IGNORED_CONTAINER_NAMES.to_set(&:to_s).freeze
|
|
59
|
-
|
|
60
60
|
class << self
|
|
61
61
|
##
|
|
62
62
|
# @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
|
|
63
63
|
# HTML string or already-parsed node (String is parsed once here)
|
|
64
64
|
# @return [Document, nil] +nil+ when no SST nodes survive normalization
|
|
65
|
-
# @raise [
|
|
65
|
+
# @raise [InvalidError] when input is nil or unsupported
|
|
66
66
|
def call(input)
|
|
67
|
-
raise
|
|
67
|
+
raise InvalidError, 'input is required' if input.nil?
|
|
68
68
|
|
|
69
69
|
new(coerce(input)).call
|
|
70
70
|
end
|
|
@@ -78,7 +78,7 @@ module Html2rss
|
|
|
78
78
|
when String then Nokogiri::HTML(input)
|
|
79
79
|
when Nokogiri::XML::Node then input
|
|
80
80
|
else
|
|
81
|
-
raise
|
|
81
|
+
raise InvalidError, "expected String or Nokogiri::XML::Node, got #{input.class}"
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
end
|
|
@@ -90,17 +90,17 @@ module Html2rss
|
|
|
90
90
|
@degraded = false
|
|
91
91
|
@parents = {}.compare_by_identity
|
|
92
92
|
@depths = {}.compare_by_identity
|
|
93
|
-
@
|
|
93
|
+
@scopes = {}.compare_by_identity
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
##
|
|
97
97
|
# @return [Document, nil] +nil+ when no SST nodes survive normalization
|
|
98
98
|
def call
|
|
99
99
|
root_nk = resolve_root_nk
|
|
100
|
-
root = normalize_element(root_nk, depth: 0, path: '',
|
|
100
|
+
root = normalize_element(root_nk, depth: 0, path: '', scope: :page)
|
|
101
101
|
return unless root
|
|
102
102
|
|
|
103
|
-
index = Index.new(root:, parents: @parents, depths: @depths,
|
|
103
|
+
index = Index.new(root:, parents: @parents, depths: @depths, scopes: @scopes)
|
|
104
104
|
Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
|
|
105
105
|
end
|
|
106
106
|
|
|
@@ -146,12 +146,12 @@ module Html2rss
|
|
|
146
146
|
node.element? && !STRIPPED_TAGS.include?(Html2rss::Html::Probe.tag(node))
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
150
|
-
def normalize_element(nk_node, depth:, path:,
|
|
149
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
150
|
+
def normalize_element(nk_node, depth:, path:, scope:)
|
|
151
151
|
return unless nk_node.element?
|
|
152
152
|
|
|
153
153
|
tag = Html::Probe.tag(nk_node)
|
|
154
|
-
return
|
|
154
|
+
return unless (nk_children = child_nodes(nk_node, tag))
|
|
155
155
|
|
|
156
156
|
if @node_count >= MAX_NODES && !@degraded
|
|
157
157
|
@degraded = true
|
|
@@ -167,18 +167,30 @@ module Html2rss
|
|
|
167
167
|
@node_count += 1
|
|
168
168
|
tag_path = "#{path}/#{tag}"
|
|
169
169
|
attrs = extract_attrs(nk_node)
|
|
170
|
-
|
|
170
|
+
scope_here = Tags.chrome_scope(nk_node, scope)
|
|
171
171
|
|
|
172
|
-
children, lead, tails = normalize_children(
|
|
172
|
+
children, lead, tails = normalize_children(nk_children, depth:, path: tag_path, scope: scope_here)
|
|
173
173
|
|
|
174
174
|
node = Node.build(name: tag, attrs:, own_text: [lead, *tails].join, children:, tag_path:, tails:)
|
|
175
175
|
@depths[node] = depth
|
|
176
|
-
@
|
|
176
|
+
@scopes[node] = scope_here
|
|
177
177
|
children.each { |child| @parents[child] = node }
|
|
178
178
|
|
|
179
179
|
node
|
|
180
180
|
end
|
|
181
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
|
+
|
|
182
194
|
def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
|
|
183
195
|
Attrs.build(
|
|
184
196
|
href: nk_node['href'],
|
|
@@ -206,13 +218,13 @@ module Html2rss
|
|
|
206
218
|
|
|
207
219
|
# Walks child nodes once, keeping direct text in the slot of the preceding kept child
|
|
208
220
|
# (slot 0 is the lead text) so text order survives the element-only tree.
|
|
209
|
-
def normalize_children(
|
|
221
|
+
def normalize_children(nk_children, depth:, path:, scope:)
|
|
210
222
|
children = []
|
|
211
223
|
slots = [+'']
|
|
212
|
-
|
|
224
|
+
nk_children.each do |child|
|
|
213
225
|
next slots.last << child.text if child.text?
|
|
214
226
|
|
|
215
|
-
node = normalize_element(child, depth: depth + 1, path:,
|
|
227
|
+
node = normalize_element(child, depth: depth + 1, path:, scope:)
|
|
216
228
|
(children << node) && (slots << +'') if node
|
|
217
229
|
end
|
|
218
230
|
[children, slots.first, slots.drop(1)]
|