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
|
@@ -8,6 +8,9 @@ module Html2rss
|
|
|
8
8
|
#
|
|
9
9
|
# {include:file:lib/html2rss/feed_resolution/README.md}
|
|
10
10
|
module FeedResolution
|
|
11
|
+
# Raised when the tournament or its policy is handed articles that are not an Array.
|
|
12
|
+
class InvalidError < Html2rss::ContractError; end
|
|
13
|
+
|
|
11
14
|
# Default entry-resolution options merged into AutoSource::DEFAULT_CONFIG.
|
|
12
15
|
DEFAULT_CONFIG = {
|
|
13
16
|
enabled: true,
|
|
@@ -116,17 +119,13 @@ module Html2rss
|
|
|
116
119
|
return unless resolution.applied
|
|
117
120
|
|
|
118
121
|
effective = scrape_target.with_effective(resolution.scrape_url)
|
|
119
|
-
|
|
120
|
-
resolution, effective:
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
nil
|
|
122
|
+
ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(effective:)
|
|
124
123
|
rescue RequestService::RequestBudgetExceeded
|
|
125
124
|
log_budget_exhausted
|
|
126
125
|
nil
|
|
127
|
-
rescue *NON_FALLBACK_ERRORS
|
|
126
|
+
rescue *NON_FALLBACK_ERRORS, Html2rss::ContractError
|
|
128
127
|
raise
|
|
129
|
-
rescue
|
|
128
|
+
rescue Html2rss::Error => error
|
|
130
129
|
log_retry_failed(error)
|
|
131
130
|
nil
|
|
132
131
|
end
|
|
@@ -158,7 +157,7 @@ module Html2rss
|
|
|
158
157
|
# @return [String, nil]
|
|
159
158
|
def channel_host
|
|
160
159
|
Url.from_absolute(config.url.to_s).host
|
|
161
|
-
rescue
|
|
160
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
162
161
|
nil
|
|
163
162
|
end
|
|
164
163
|
|
|
@@ -184,24 +183,20 @@ module Html2rss
|
|
|
184
183
|
)
|
|
185
184
|
end
|
|
186
185
|
|
|
187
|
-
def retry_with(
|
|
186
|
+
def retry_with(effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
|
|
188
187
|
retry_session = pipeline.request_session_for(
|
|
189
188
|
config, strategy:, resources:, scrape_url: effective.effective_url
|
|
190
189
|
)
|
|
191
190
|
retry_response = retry_session.fetch_initial_response
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
191
|
+
extraction = pipeline.extract(config:, response: retry_response, request_session: retry_session)
|
|
192
|
+
items_count = extraction.articles.size
|
|
195
193
|
state.remember_response(retry_response)
|
|
196
|
-
state.record_items(
|
|
197
|
-
|
|
198
|
-
)
|
|
199
|
-
return unless retry_articles.size.positive?
|
|
194
|
+
state.record_items(strategy:, items_count:, transport_meta: retry_response.transport_meta)
|
|
195
|
+
return unless items_count.positive?
|
|
200
196
|
|
|
201
197
|
state.succeed!(
|
|
202
|
-
response: retry_response,
|
|
203
|
-
|
|
204
|
-
scrape_target: effective
|
|
198
|
+
response: retry_response, extraction:, selected_strategy: strategy,
|
|
199
|
+
attempt_count: state.attempts.size, scrape_target: effective
|
|
205
200
|
)
|
|
206
201
|
true
|
|
207
202
|
end
|
|
@@ -218,9 +213,9 @@ module Html2rss
|
|
|
218
213
|
# @param surface_category [Symbol, nil]
|
|
219
214
|
# rubocop:disable-next Metrics/ParameterLists -- tournament context stays co-located
|
|
220
215
|
def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
|
|
221
|
-
raise
|
|
216
|
+
raise InvalidError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
222
217
|
|
|
223
|
-
@entry_url =
|
|
218
|
+
@entry_url = Url.from_absolute(entry_url)
|
|
224
219
|
@response = response
|
|
225
220
|
@session = session
|
|
226
221
|
@config = config
|
data/lib/html2rss/feed_result.rb
CHANGED
|
@@ -15,17 +15,20 @@ module Html2rss
|
|
|
15
15
|
# are re-frozen via private {#marshal_load}. For trusted cache reads only,
|
|
16
16
|
# +Marshal.load(payload, freeze: true)+ is also safe.
|
|
17
17
|
class FeedResult
|
|
18
|
+
# Raised when the scrape handle is built from invalid parts.
|
|
19
|
+
class InvalidError < Html2rss::ContractError; end
|
|
20
|
+
|
|
18
21
|
##
|
|
19
22
|
# @param channel [Html2rss::Channel] channel metadata (internal; not exposed on the public API)
|
|
20
23
|
# @param articles [Array<Html2rss::Article>] extracted articles (deduplicated; not exposed)
|
|
21
24
|
# @param status [Html2rss::Status] pipeline telemetry
|
|
22
25
|
# @param stylesheets [Array<Hash>] optional RSS stylesheet configs
|
|
26
|
+
# @raise [InvalidError] when a part has the wrong type
|
|
23
27
|
def initialize(channel:, articles:, status:, stylesheets: [])
|
|
24
|
-
raise
|
|
25
|
-
raise
|
|
26
|
-
|
|
27
|
-
articles
|
|
28
|
-
raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
|
|
28
|
+
raise InvalidError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
|
|
29
|
+
raise InvalidError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
|
|
30
|
+
raise InvalidError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
31
|
+
raise InvalidError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
|
|
29
32
|
|
|
30
33
|
@channel = channel
|
|
31
34
|
@articles = articles.dup.freeze
|
|
@@ -82,7 +85,9 @@ module Html2rss
|
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
def freeze_stylesheets(stylesheets)
|
|
85
|
-
Array(
|
|
88
|
+
raise InvalidError, 'stylesheets must be an Array' unless stylesheets.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
stylesheets.map do |sheet|
|
|
86
91
|
sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
|
|
87
92
|
value.is_a?(String) ? value.dup.freeze : value
|
|
88
93
|
end.freeze
|
data/lib/html2rss/hash_util.rb
CHANGED
|
@@ -3,116 +3,117 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
# Shared helpers for hash normalization and structural operations.
|
|
5
5
|
module HashUtil
|
|
6
|
-
|
|
6
|
+
class << self
|
|
7
|
+
# Deeply duplicates nested arrays and hashes.
|
|
8
|
+
#
|
|
9
|
+
# @param object [Object] nested value from configuration or runtime state
|
|
10
|
+
# @return [Object] deep duplicated object
|
|
11
|
+
def deep_dup(object)
|
|
12
|
+
case object
|
|
13
|
+
in Hash
|
|
14
|
+
object.transform_values { deep_dup(_1) }
|
|
15
|
+
in Array
|
|
16
|
+
object.map { deep_dup(_1) }
|
|
17
|
+
else
|
|
18
|
+
object.dup
|
|
19
|
+
end
|
|
20
|
+
end
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
# Deeply merges nested hashes while replacing non-hash values from override.
|
|
23
|
+
#
|
|
24
|
+
# @param base [Hash] base hash
|
|
25
|
+
# @param override [Hash] override hash
|
|
26
|
+
# @return [Hash] merged hash
|
|
27
|
+
def deep_merge(base, override)
|
|
28
|
+
base.merge(override) do |_key, old_val, new_val|
|
|
29
|
+
case [old_val, new_val]
|
|
30
|
+
in [Hash, Hash]
|
|
31
|
+
deep_merge(old_val, new_val)
|
|
32
|
+
else
|
|
33
|
+
new_val
|
|
34
|
+
end
|
|
35
|
+
end
|
|
20
36
|
end
|
|
21
|
-
end
|
|
22
37
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
# Converts string-keyed hashes to symbol-keyed hashes recursively.
|
|
39
|
+
#
|
|
40
|
+
# @param object [Object] value to normalize
|
|
41
|
+
# @param context [String] error context
|
|
42
|
+
# @return [Object] normalized value
|
|
43
|
+
def deep_symbolize_keys(object, context: 'hash')
|
|
44
|
+
case object
|
|
45
|
+
in Hash
|
|
46
|
+
object.each_with_object({}) do |(k, v), memo|
|
|
47
|
+
memo[symbol_key(k, context:)] = deep_symbolize_keys(v, context:)
|
|
48
|
+
end
|
|
49
|
+
in Array
|
|
50
|
+
object.map { deep_symbolize_keys(_1, context:) }
|
|
33
51
|
else
|
|
34
|
-
|
|
52
|
+
object
|
|
35
53
|
end
|
|
36
54
|
end
|
|
37
|
-
end
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
# Converts hash keys (and Symbol values) to strings recursively.
|
|
57
|
+
#
|
|
58
|
+
# @param object [Object] value to normalize
|
|
59
|
+
# @return [Object] normalized value with string keys
|
|
60
|
+
def deep_stringify_keys(object)
|
|
61
|
+
case object
|
|
62
|
+
when Hash
|
|
63
|
+
object.to_h { |key, value| [key.to_s, deep_stringify_keys(value)] }
|
|
64
|
+
when Array
|
|
65
|
+
object.map { deep_stringify_keys(_1) }
|
|
66
|
+
when Symbol
|
|
67
|
+
object.to_s
|
|
68
|
+
else
|
|
69
|
+
object
|
|
49
70
|
end
|
|
50
|
-
in Array
|
|
51
|
-
object.map { deep_symbolize_keys(_1, context:) }
|
|
52
|
-
else
|
|
53
|
-
object
|
|
54
71
|
end
|
|
55
|
-
end
|
|
56
72
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
when Array
|
|
66
|
-
object.map { deep_stringify_keys(_1) }
|
|
67
|
-
when Symbol
|
|
68
|
-
object.to_s
|
|
69
|
-
else
|
|
70
|
-
object
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
+
# Validates that hash keys are symbols.
|
|
74
|
+
#
|
|
75
|
+
# @param value [Object] candidate hash container whose keys must be symbols
|
|
76
|
+
# @param context [String] error context
|
|
77
|
+
# @param deep [Boolean] whether nested hashes should also be validated
|
|
78
|
+
# @return [void]
|
|
79
|
+
def assert_symbol_keys!(value, context: 'hash', deep: true)
|
|
80
|
+
return unless value in Hash
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# @param deep [Boolean] whether nested hashes should also be validated
|
|
79
|
-
# @return [void]
|
|
80
|
-
def assert_symbol_keys!(value, context: 'hash', deep: true)
|
|
81
|
-
return unless value in Hash
|
|
82
|
+
unless value.each_key.all?(Symbol)
|
|
83
|
+
invalid_key = value.keys.find { _1.class != Symbol }
|
|
84
|
+
raise InvalidOption, "#{context} must use symbol keys (found #{invalid_key.inspect})"
|
|
85
|
+
end
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
invalid_key = value.keys.find { _1.class != Symbol }
|
|
85
|
-
raise ArgumentError, "#{context} must use symbol keys (found #{invalid_key.inspect})"
|
|
87
|
+
value.each_value { assert_symbol_keys!(_1, context:, deep:) } if deep
|
|
86
88
|
end
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
# Validates that hash keys are strings.
|
|
91
|
+
#
|
|
92
|
+
# @param value [Object] candidate hash container whose keys must be strings
|
|
93
|
+
# @param context [String] error context
|
|
94
|
+
# @param deep [Boolean] whether nested hashes should also be validated
|
|
95
|
+
# @return [void]
|
|
96
|
+
def assert_string_keys!(value, context: 'hash', deep: true)
|
|
97
|
+
return unless value in Hash
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# @param deep [Boolean] whether nested hashes should also be validated
|
|
96
|
-
# @return [void]
|
|
97
|
-
def assert_string_keys!(value, context: 'hash', deep: true)
|
|
98
|
-
return unless value in Hash
|
|
99
|
+
unless value.each_key.all?(String)
|
|
100
|
+
invalid_key = value.keys.find { _1.class != String }
|
|
101
|
+
raise InvalidOption, "#{context} must use string keys (found #{invalid_key.inspect})"
|
|
102
|
+
end
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
invalid_key = value.keys.find { _1.class != String }
|
|
102
|
-
raise ArgumentError, "#{context} must use string keys (found #{invalid_key.inspect})"
|
|
104
|
+
value.each_value { assert_string_keys!(_1, context:, deep:) } if deep
|
|
103
105
|
end
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
end
|
|
107
|
+
private
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
def symbol_key(key, context:)
|
|
110
|
+
case key
|
|
111
|
+
in Symbol then key
|
|
112
|
+
in String then key.to_sym
|
|
113
|
+
else
|
|
114
|
+
raise InvalidOption, "#{context} must use string or symbol keys (found #{key.inspect})"
|
|
115
|
+
end
|
|
114
116
|
end
|
|
115
117
|
end
|
|
116
|
-
private_class_method :symbol_key
|
|
117
118
|
end
|
|
118
119
|
end
|
|
@@ -28,24 +28,21 @@ module Html2rss
|
|
|
28
28
|
def self.call(article_tag, title: nil)
|
|
29
29
|
return [] unless article_tag
|
|
30
30
|
|
|
31
|
-
extract_all_categories(article_tag, title:)
|
|
32
|
-
.map(&:strip)
|
|
33
|
-
.reject(&:empty?)
|
|
31
|
+
extract_all_categories(article_tag, title:).to_a
|
|
34
32
|
end
|
|
35
33
|
|
|
36
34
|
##
|
|
37
35
|
# Optimized single DOM traversal that extracts all category types.
|
|
36
|
+
# `Node#css` only yields descendants, so the container itself is never visited.
|
|
38
37
|
#
|
|
39
38
|
# @param article_tag [Nokogiri::XML::Element] The article element
|
|
40
39
|
# @param title [String, nil]
|
|
41
40
|
# @return [Set<String>] Set of category strings
|
|
42
|
-
def self.extract_all_categories(article_tag, title:
|
|
41
|
+
def self.extract_all_categories(article_tag, title:)
|
|
43
42
|
Set.new.tap do |categories|
|
|
44
43
|
extract_element_data_categories!(categories, article_tag, title:)
|
|
45
44
|
article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if ArticleRules::Category.class_match?(element['class'])
|
|
45
|
+
if ArticleRules::Category.class_match?(element['class'].to_s.split)
|
|
49
46
|
extract_text_categories!(categories, element, title:)
|
|
50
47
|
end
|
|
51
48
|
extract_element_data_categories!(categories, element, title:)
|
|
@@ -60,7 +57,7 @@ module Html2rss
|
|
|
60
57
|
# @param element [Nokogiri::XML::Element] metadata element that may contain category links
|
|
61
58
|
# @param title [String, nil]
|
|
62
59
|
# @return [void]
|
|
63
|
-
def self.extract_element_data_categories!(categories, element, title:
|
|
60
|
+
def self.extract_element_data_categories!(categories, element, title:)
|
|
64
61
|
element.attributes.each_value do |attr|
|
|
65
62
|
next unless ArticleRules::Category.attr_name_match?(attr.name)
|
|
66
63
|
|
|
@@ -75,7 +72,7 @@ module Html2rss
|
|
|
75
72
|
# @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
|
|
76
73
|
# @param title [String, nil]
|
|
77
74
|
# @return [void]
|
|
78
|
-
def self.extract_text_categories!(categories, element, title:
|
|
75
|
+
def self.extract_text_categories!(categories, element, title:)
|
|
79
76
|
return if category_container?(element)
|
|
80
77
|
|
|
81
78
|
anchors = element.name == 'a' ? [element] : element.css('a').to_a
|
|
@@ -96,7 +93,7 @@ module Html2rss
|
|
|
96
93
|
# @param element [Nokogiri::XML::Element] The element to extract text from
|
|
97
94
|
# @param title [String, nil]
|
|
98
95
|
# @return [void]
|
|
99
|
-
def self.add_text_to_categories!(categories, element, title:
|
|
96
|
+
def self.add_text_to_categories!(categories, element, title:)
|
|
100
97
|
ArticleRules::Category.add_text!(categories, Navigator.extract_visible_text(element), title:)
|
|
101
98
|
end
|
|
102
99
|
|
|
@@ -9,7 +9,7 @@ module Html2rss
|
|
|
9
9
|
# @param leftover_lines [Array<String>] leftover visible lines (already split)
|
|
10
10
|
# @param time_zone [String] channel time zone for naive values
|
|
11
11
|
# @return [DateTime, nil]
|
|
12
|
-
def self.call(article_tag, leftover_lines: []
|
|
12
|
+
def self.call(article_tag, time_zone:, leftover_lines: [])
|
|
13
13
|
datetimes = article_tag.css('[datetime]').map { |tag| tag['datetime'] }
|
|
14
14
|
ArticleRules::Date.earliest(datetimes, leftover_lines:, time_zone:)
|
|
15
15
|
end
|
|
@@ -4,46 +4,25 @@ module Html2rss
|
|
|
4
4
|
module Html
|
|
5
5
|
class ArticleExtractor
|
|
6
6
|
##
|
|
7
|
-
#
|
|
7
|
+
# Adapts Nokogiri elements to {ArticleRules::Enclosure}, which owns which elements become enclosures.
|
|
8
8
|
class EnclosureExtractor
|
|
9
|
-
# CSS union
|
|
10
|
-
SELECTOR =
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
'audio source[src]',
|
|
14
|
-
'audio[src]',
|
|
15
|
-
'a[href$=".pdf"]',
|
|
16
|
-
'iframe[src]',
|
|
17
|
-
'a[href$=".zip"]',
|
|
18
|
-
'a[href$=".tar.gz"]',
|
|
19
|
-
'a[href$=".tgz"]'
|
|
20
|
-
].join(',').freeze
|
|
9
|
+
# CSS union of the element names {ArticleRules::Enclosure} may turn into an enclosure.
|
|
10
|
+
SELECTOR = ArticleRules::Enclosure::ELEMENT_NAMES.join(',').freeze
|
|
11
|
+
# CSS union of the players a +source+ element must sit inside.
|
|
12
|
+
PLAYERS = ArticleRules::Enclosure::MEDIA_ELEMENTS.join(',').freeze
|
|
21
13
|
|
|
22
14
|
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
23
15
|
# @param base_url [String, Html2rss::Url] base URL for relative enclosure links
|
|
24
|
-
# @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
|
|
16
|
+
# @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes in document order, one per URL
|
|
25
17
|
def self.call(article_tag, base_url)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
18
|
+
found = article_tag.css(SELECTOR).filter_map do |element|
|
|
19
|
+
attrs = { src: element['src'], href: element['href'], type: element['type'] }
|
|
20
|
+
ArticleRules::Enclosure.from_element(element.name, base_url, **attrs) do
|
|
21
|
+
element.ancestors(PLAYERS).first&.name
|
|
22
|
+
end
|
|
30
23
|
end
|
|
24
|
+
ArticleRules::Enclosure.unique(found)
|
|
31
25
|
end
|
|
32
|
-
|
|
33
|
-
def self.extract_from_element(element, base_url)
|
|
34
|
-
case element.name
|
|
35
|
-
when 'img'
|
|
36
|
-
ArticleRules::Enclosure.from_image(element['src'], base_url)
|
|
37
|
-
when 'video', 'audio', 'source'
|
|
38
|
-
ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
|
|
39
|
-
when 'iframe'
|
|
40
|
-
ArticleRules::Enclosure.from_iframe(element['src'], base_url)
|
|
41
|
-
when 'a'
|
|
42
|
-
ArticleRules::Enclosure.from_anchor(element['href'], base_url)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private_class_method :extract_from_element
|
|
47
26
|
end
|
|
48
27
|
end
|
|
49
28
|
end
|
|
@@ -12,10 +12,9 @@ module Html2rss
|
|
|
12
12
|
class << self
|
|
13
13
|
##
|
|
14
14
|
# @param article_tag [Nokogiri::XML::Element] container node
|
|
15
|
-
# @param fallback_anchorless [Boolean] whether to use fallback search
|
|
16
15
|
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
17
16
|
# @return [Nokogiri::XML::Node, nil] the heading node, if found
|
|
18
|
-
def call(article_tag,
|
|
17
|
+
def call(article_tag, selected_anchor:)
|
|
19
18
|
tags = article_tag.css(HEADING_TAGS.join(','))
|
|
20
19
|
if tags.any?
|
|
21
20
|
select_best_heading(tags)
|
|
@@ -23,7 +22,7 @@ module Html2rss
|
|
|
23
22
|
labeled = heading_from_aria_or_title(article_tag)
|
|
24
23
|
return labeled if labeled
|
|
25
24
|
|
|
26
|
-
fallback_heading(article_tag) if
|
|
25
|
+
fallback_heading(article_tag) if selected_anchor.nil?
|
|
27
26
|
end
|
|
28
27
|
end
|
|
29
28
|
|
|
@@ -14,16 +14,15 @@ module Html2rss
|
|
|
14
14
|
# @param heading [Nokogiri::XML::Node, nil] heading node
|
|
15
15
|
# @param url [Html2rss::Url, nil] absolute article URL
|
|
16
16
|
# @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
|
|
17
|
-
# @param fallback_anchorless [Boolean] whether to use fallback hashing
|
|
18
17
|
# @return [String, nil] the generated ID, if any
|
|
19
|
-
def call(article_tag, heading:, url:, selected_anchor
|
|
18
|
+
def call(article_tag, heading:, url:, selected_anchor:)
|
|
20
19
|
id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
|
|
21
20
|
return id_from_dom if id_from_dom
|
|
22
21
|
|
|
23
|
-
heading_text = resolve_heading_text(article_tag, heading
|
|
22
|
+
heading_text = resolve_heading_text(article_tag, heading)
|
|
24
23
|
if heading_text && !heading_text.strip.empty?
|
|
25
24
|
generate_slug(heading_text)
|
|
26
|
-
|
|
25
|
+
else
|
|
27
26
|
generate_content_hash(article_tag)
|
|
28
27
|
end
|
|
29
28
|
end
|
|
@@ -32,22 +31,20 @@ module Html2rss
|
|
|
32
31
|
|
|
33
32
|
def parse_id_from_dom(article_tag, url, selected_anchor)
|
|
34
33
|
candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
|
|
35
|
-
candidates += [url&.path
|
|
34
|
+
candidates += [url&.path] if selected_anchor
|
|
36
35
|
candidates.compact.reject(&:empty?).first
|
|
37
36
|
end
|
|
38
37
|
|
|
39
|
-
def resolve_heading_text(article_tag, heading
|
|
38
|
+
def resolve_heading_text(article_tag, heading)
|
|
40
39
|
text = heading ? Navigator::TextExtractor.call(heading) : nil
|
|
41
40
|
if text.nil? || text.strip.empty?
|
|
42
|
-
fallback_text_node_content(article_tag
|
|
41
|
+
fallback_text_node_content(article_tag)
|
|
43
42
|
else
|
|
44
43
|
text
|
|
45
44
|
end
|
|
46
45
|
end
|
|
47
46
|
|
|
48
|
-
def fallback_text_node_content(article_tag
|
|
49
|
-
return unless fallback_anchorless
|
|
50
|
-
|
|
47
|
+
def fallback_text_node_content(article_tag)
|
|
51
48
|
article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
|
|
52
49
|
end
|
|
53
50
|
|
|
@@ -12,7 +12,8 @@ module Html2rss
|
|
|
12
12
|
def self.call(article_tag, base_url:)
|
|
13
13
|
img_src = from_source(article_tag) ||
|
|
14
14
|
from_img(article_tag) ||
|
|
15
|
-
from_style(article_tag)
|
|
15
|
+
from_style(article_tag) ||
|
|
16
|
+
from_poster(article_tag)
|
|
16
17
|
|
|
17
18
|
Url.from_relative(img_src, base_url) if img_src
|
|
18
19
|
end
|
|
@@ -43,6 +44,13 @@ module Html2rss
|
|
|
43
44
|
styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
|
|
44
45
|
ArticleRules::Image.best_from_styles(styles)
|
|
45
46
|
end
|
|
47
|
+
|
|
48
|
+
# @param article_tag [Nokogiri::XML::Element] article container node
|
|
49
|
+
# @return [String, nil] first usable video poster frame: the last resort, when the card has no other image
|
|
50
|
+
def self.from_poster(article_tag)
|
|
51
|
+
posters = article_tag.css('video[poster]').map { |video| video['poster'] }
|
|
52
|
+
posters.find { |src| ArticleRules::Image.remote_src?(src) }
|
|
53
|
+
end
|
|
46
54
|
end
|
|
47
55
|
end
|
|
48
56
|
end
|