html2rss 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -21
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +126 -50
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +49 -6
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +4 -0
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +100 -9
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +0 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +6 -6
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors.rb +53 -12
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class RequestService
|
|
7
|
+
##
|
|
8
|
+
# Opt-in reuse of initial page fetches, e.g. while a person iterates on selectors for one page.
|
|
9
|
+
# Extended onto {RequestService}; the cache is consulted only for +relation == :initial+ requests,
|
|
10
|
+
# follow-ups always hit the network.
|
|
11
|
+
#
|
|
12
|
+
# The key is a SHA256 digest of the URL, strategy, headers, request options and the policy fields
|
|
13
|
+
# that shape a response, so credentials never reach the cache and a response fetched under a
|
|
14
|
+
# permissive {Policy} is never served to a stricter one. Cookie-store state is not part of the key.
|
|
15
|
+
module ResponseCaching
|
|
16
|
+
# Mutex guarding the process-wide response cache reference.
|
|
17
|
+
RESPONSE_CACHE_MUTEX = Mutex.new
|
|
18
|
+
# Fiber-storage key for the active {#observe_cache_hits} collector.
|
|
19
|
+
CACHE_HITS_KEY = :html2rss_cache_hits
|
|
20
|
+
# Fiber-storage key for the cache installed by {#with_response_cache}.
|
|
21
|
+
SCOPED_CACHE_KEY = :html2rss_scoped_response_cache
|
|
22
|
+
private_constant :RESPONSE_CACHE_MUTEX, :CACHE_HITS_KEY, :SCOPED_CACHE_KEY
|
|
23
|
+
|
|
24
|
+
# @param cache [#fetch, nil] receives +fetch(key) { response }+ and returns the cached or freshly
|
|
25
|
+
# built {Response} (tagged with {Response#cache_age} on a hit), e.g. {ResponseCache};
|
|
26
|
+
# +nil+ (the default) disables caching. A raising block must not be cached.
|
|
27
|
+
# @return [void]
|
|
28
|
+
def response_cache=(cache)
|
|
29
|
+
RESPONSE_CACHE_MUTEX.synchronize { @response_cache = cache }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [#fetch, nil] the scoped cache from {#with_response_cache} if one is active, otherwise
|
|
33
|
+
# the globally configured initial-response cache, if any
|
|
34
|
+
def response_cache
|
|
35
|
+
scoped = Fiber[SCOPED_CACHE_KEY]
|
|
36
|
+
return scoped.first if scoped
|
|
37
|
+
|
|
38
|
+
RESPONSE_CACHE_MUTEX.synchronize { @response_cache }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Runs the block with +cache+ as the initial-response cache for the current fiber (and fibers or
|
|
42
|
+
# threads it spawns), taking precedence over {#response_cache=}. The previous scope is restored
|
|
43
|
+
# when the block exits, even by raising.
|
|
44
|
+
#
|
|
45
|
+
# @param cache [#fetch, nil] cache for the block; +nil+ disables caching inside it
|
|
46
|
+
# @yieldreturn [Object] the block's result
|
|
47
|
+
# @return [Object] the block's result
|
|
48
|
+
def with_response_cache(cache)
|
|
49
|
+
previous = Fiber[SCOPED_CACHE_KEY]
|
|
50
|
+
Fiber[SCOPED_CACHE_KEY] = [cache]
|
|
51
|
+
yield
|
|
52
|
+
ensure
|
|
53
|
+
Fiber[SCOPED_CACHE_KEY] = previous
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runs the block and reports the cache ages of every initial fetch it served from {#response_cache},
|
|
57
|
+
# including fetches on threads the block spawns (fiber storage is inherited by reference).
|
|
58
|
+
#
|
|
59
|
+
# @yieldreturn [Object] the block's result
|
|
60
|
+
# @return [Array(Object, Array<Integer>)] the block's result and the served cache ages in seconds
|
|
61
|
+
def observe_cache_hits
|
|
62
|
+
hits = Fiber[CACHE_HITS_KEY] = Queue.new
|
|
63
|
+
result = yield
|
|
64
|
+
[result, Array.new(hits.size) { hits.pop }]
|
|
65
|
+
ensure
|
|
66
|
+
Fiber[CACHE_HITS_KEY] = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# Runs +fetch+ directly, or through {#response_cache} for initial requests.
|
|
73
|
+
# Callers always receive their own {Response} so parsed documents are never shared.
|
|
74
|
+
# @param ctx [Context] the request context
|
|
75
|
+
# @param strategy [Symbol] the resolved strategy name
|
|
76
|
+
# @yieldreturn [Response] the freshly fetched response
|
|
77
|
+
# @return [Response]
|
|
78
|
+
def fetch_initial_through_cache(ctx, strategy, &)
|
|
79
|
+
cache = response_cache
|
|
80
|
+
return yield unless cache && ctx.relation == :initial
|
|
81
|
+
|
|
82
|
+
cache.fetch(response_cache_key(ctx, strategy), &).replica.tap { |response| record_cache_hit(response) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def record_cache_hit(response)
|
|
86
|
+
Fiber[CACHE_HITS_KEY]&.push(response.cache_age) if response.cache_age
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def response_cache_key(ctx, strategy)
|
|
90
|
+
parts = [ctx.url.to_s, strategy, ctx.headers.sort, ctx.request, ctx.policy.cache_key]
|
|
91
|
+
Digest::SHA256.hexdigest(parts.inspect)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -18,12 +18,13 @@ module Html2rss
|
|
|
18
18
|
# Executes the request through the shared preflight/postflight path.
|
|
19
19
|
#
|
|
20
20
|
# @return [Response] the response from the strategy
|
|
21
|
-
# @raise [NotImplementedError] if the subclass does not implement `#fetch`
|
|
22
21
|
def execute
|
|
23
22
|
check_timeout!
|
|
24
23
|
response = perform_execute
|
|
25
24
|
postflight!(response)
|
|
26
25
|
response
|
|
26
|
+
rescue Html2rss::ContractError
|
|
27
|
+
raise
|
|
27
28
|
rescue StandardError => error
|
|
28
29
|
handle_error(error)
|
|
29
30
|
end
|
|
@@ -42,15 +43,6 @@ module Html2rss
|
|
|
42
43
|
fetch
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
##
|
|
46
|
-
# Adapter hook: perform the transport-specific fetch after preflight.
|
|
47
|
-
#
|
|
48
|
-
# @return [Response] normalized response
|
|
49
|
-
# @raise [NotImplementedError] when a subclass omits the hook
|
|
50
|
-
def fetch
|
|
51
|
-
raise NotImplementedError, 'Subclass must implement #fetch'
|
|
52
|
-
end
|
|
53
|
-
|
|
54
46
|
##
|
|
55
47
|
# Consumes a request slot and validates the destination before transport work.
|
|
56
48
|
#
|
|
@@ -60,7 +52,9 @@ module Html2rss
|
|
|
60
52
|
return if skip_preflight?
|
|
61
53
|
|
|
62
54
|
ctx.budget.consume! if consume_budget
|
|
63
|
-
ctx.policy.validate_request!(
|
|
55
|
+
ctx.policy.validate_request!(
|
|
56
|
+
url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation, advertisement: ctx.advertisement
|
|
57
|
+
)
|
|
64
58
|
end
|
|
65
59
|
|
|
66
60
|
##
|
|
@@ -8,28 +8,67 @@ module Html2rss
|
|
|
8
8
|
# Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
|
|
9
9
|
# it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
|
|
10
10
|
class RequestService
|
|
11
|
+
extend ResponseCaching
|
|
12
|
+
|
|
11
13
|
# Raised when an unknown request strategy is requested.
|
|
12
14
|
class UnknownStrategy < Html2rss::Error; end
|
|
15
|
+
|
|
13
16
|
# Raised when a URL cannot be parsed or validated.
|
|
14
|
-
class InvalidUrl < Html2rss::Error
|
|
17
|
+
class InvalidUrl < Html2rss::Error
|
|
18
|
+
# @return [Symbol]
|
|
19
|
+
def kind = :invalid_url
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
# Raised when a URL uses an unsupported scheme.
|
|
16
|
-
class UnsupportedUrlScheme < Html2rss::Error
|
|
23
|
+
class UnsupportedUrlScheme < Html2rss::Error
|
|
24
|
+
# @return [Symbol]
|
|
25
|
+
def kind = :invalid_url
|
|
26
|
+
end
|
|
27
|
+
|
|
17
28
|
# Raised when a response type cannot be parsed.
|
|
18
29
|
class UnsupportedResponseContentType < Html2rss::Error; end
|
|
30
|
+
|
|
19
31
|
# Raised when HTTP request slot limits are exceeded.
|
|
20
|
-
class RequestBudgetExceeded < Html2rss::Error
|
|
32
|
+
class RequestBudgetExceeded < Html2rss::Error
|
|
33
|
+
# @return [Symbol]
|
|
34
|
+
def kind = :budget
|
|
35
|
+
end
|
|
36
|
+
|
|
21
37
|
# Raised when policy denies private-network access.
|
|
22
|
-
class PrivateNetworkDenied < Html2rss::Error
|
|
38
|
+
class PrivateNetworkDenied < Html2rss::Error
|
|
39
|
+
# @return [Symbol]
|
|
40
|
+
def kind = :blocked
|
|
41
|
+
end
|
|
42
|
+
|
|
23
43
|
# Raised when cross-origin follow-up requests are denied.
|
|
24
|
-
class CrossOriginFollowUpDenied < Html2rss::Error
|
|
44
|
+
class CrossOriginFollowUpDenied < Html2rss::Error
|
|
45
|
+
# @return [Symbol]
|
|
46
|
+
def kind = :blocked
|
|
47
|
+
end
|
|
48
|
+
|
|
25
49
|
# Raised when a response exceeds configured size limits.
|
|
26
|
-
class ResponseTooLarge < Html2rss::Error
|
|
50
|
+
class ResponseTooLarge < Html2rss::Error
|
|
51
|
+
# @return [Symbol]
|
|
52
|
+
def kind = :budget
|
|
53
|
+
end
|
|
54
|
+
|
|
27
55
|
# Raised when HTTP redirect limits are exceeded.
|
|
28
|
-
class RedirectLimitReached < Html2rss::Error
|
|
56
|
+
class RedirectLimitReached < Html2rss::Error
|
|
57
|
+
# @return [Symbol]
|
|
58
|
+
def kind = :budget
|
|
59
|
+
end
|
|
60
|
+
|
|
29
61
|
# Raised when blocked content surfaces are detected.
|
|
30
|
-
class BlockedSurfaceDetected < Html2rss::Error
|
|
62
|
+
class BlockedSurfaceDetected < Html2rss::Error
|
|
63
|
+
# @return [Symbol]
|
|
64
|
+
def kind = :blocked
|
|
65
|
+
end
|
|
66
|
+
|
|
31
67
|
# Raised when the target connection fails before a response document exists.
|
|
32
|
-
class ConnectionFailed < Html2rss::Error
|
|
68
|
+
class ConnectionFailed < Html2rss::Error
|
|
69
|
+
# @return [Symbol]
|
|
70
|
+
def kind = :connection
|
|
71
|
+
end
|
|
33
72
|
|
|
34
73
|
# Raised when a request times out.
|
|
35
74
|
class RequestTimedOut < Html2rss::Error
|
|
@@ -43,6 +82,9 @@ module Html2rss
|
|
|
43
82
|
|
|
44
83
|
# @return [String, nil] scrape-api timeout stage, or nil for transport/budget timeouts
|
|
45
84
|
attr_reader :timeout_phase
|
|
85
|
+
|
|
86
|
+
# @return [Symbol]
|
|
87
|
+
def kind = :timeout
|
|
46
88
|
end
|
|
47
89
|
|
|
48
90
|
# Raised when Botasaurus configuration is missing or invalid.
|
|
@@ -52,7 +94,11 @@ module Html2rss
|
|
|
52
94
|
end
|
|
53
95
|
|
|
54
96
|
# Raised when the Botasaurus service cannot be reached (network / DNS / SSL).
|
|
55
|
-
class BotasaurusConnectionFailed < Html2rss::Error
|
|
97
|
+
class BotasaurusConnectionFailed < Html2rss::Error
|
|
98
|
+
# @return [Symbol]
|
|
99
|
+
def kind = :connection
|
|
100
|
+
end
|
|
101
|
+
|
|
56
102
|
# Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
|
|
57
103
|
class BotasaurusServiceError < Html2rss::Error; end
|
|
58
104
|
|
|
@@ -82,9 +128,22 @@ module Html2rss
|
|
|
82
128
|
MAX_DEPRECATION_WARNINGS = 256
|
|
83
129
|
# Mutex guarding process-local deprecation warning deduplication.
|
|
84
130
|
DEPRECATION_MUTEX = Mutex.new
|
|
85
|
-
|
|
131
|
+
# Mutex guarding the process-wide cookie store reference.
|
|
132
|
+
COOKIE_STORE_MUTEX = Mutex.new
|
|
133
|
+
private_constant :DEPRECATION_MUTEX, :COOKIE_STORE_MUTEX
|
|
86
134
|
|
|
87
135
|
class << self
|
|
136
|
+
# @param store [#fetch, #write, #evict, nil] replacement cookie store (nil restores the in-memory default)
|
|
137
|
+
# @return [void]
|
|
138
|
+
def cookie_store=(store)
|
|
139
|
+
COOKIE_STORE_MUTEX.synchronize { @cookie_store = store }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [#fetch, #write, #evict] the configured cookie store (in-memory by default)
|
|
143
|
+
def cookie_store
|
|
144
|
+
COOKIE_STORE_MUTEX.synchronize { @cookie_store ||= CookieStore.new }
|
|
145
|
+
end
|
|
146
|
+
|
|
88
147
|
# @return [Symbol] the default strategy name
|
|
89
148
|
def default_strategy_name = DEFAULT_STRATEGY_NAME
|
|
90
149
|
|
|
@@ -112,7 +171,7 @@ module Html2rss
|
|
|
112
171
|
"The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
|
|
113
172
|
end
|
|
114
173
|
|
|
115
|
-
strategy_class.new(ctx).execute
|
|
174
|
+
fetch_initial_through_cache(ctx, strategy_sym) { strategy_class.new(ctx).execute }
|
|
116
175
|
end
|
|
117
176
|
|
|
118
177
|
##
|
|
@@ -70,13 +70,6 @@ module Html2rss
|
|
|
70
70
|
)
|
|
71
71
|
nil
|
|
72
72
|
end
|
|
73
|
-
|
|
74
|
-
# @param page_response [RequestService::Response]
|
|
75
|
-
# @param page_number [Integer] 1-based target page number (e.g. 2 for second page)
|
|
76
|
-
# @return [Html2rss::Url, String, nil]
|
|
77
|
-
def next_page_url(page_response, page_number:)
|
|
78
|
-
raise NotImplementedError, "#{self.class}#next_page_url must be implemented by subclass"
|
|
79
|
-
end
|
|
80
73
|
end
|
|
81
74
|
end
|
|
82
75
|
end
|
|
@@ -47,7 +47,7 @@ module Html2rss
|
|
|
47
47
|
strategy_name = normalized_config.fetch(:strategy, :rel_next).to_sym
|
|
48
48
|
|
|
49
49
|
klass = STRATEGIES.fetch(strategy_name) do
|
|
50
|
-
raise
|
|
50
|
+
raise InvalidOption, "Unknown pagination strategy: #{strategy_name}"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
klass.new(session:, initial_response:, config: normalized_config)
|
|
@@ -65,9 +65,13 @@ module Html2rss
|
|
|
65
65
|
# @param url [String, Html2rss::Url] follow-up request url
|
|
66
66
|
# @param relation [Symbol] why the follow-up is being made
|
|
67
67
|
# @param origin_url [String, Html2rss::Url] effective origin for same-origin checks
|
|
68
|
+
# @param advertisement [RequestService::FeedAdvertisement, nil] the origin page's advertised feeds;
|
|
69
|
+
# with relation +:advertised_feed+, {RequestService::Policy} lets a listed feed leave the origin
|
|
68
70
|
# @return [RequestService::Response] follow-up response
|
|
69
|
-
def follow_up(url:, relation:, origin_url:)
|
|
70
|
-
execute(context.follow_up(url:, relation:, origin_url:)).tap
|
|
71
|
+
def follow_up(url:, relation:, origin_url:, advertisement: nil)
|
|
72
|
+
execute(context.follow_up(url:, relation:, origin_url:, advertisement:)).tap do |response|
|
|
73
|
+
remember!(response.url)
|
|
74
|
+
end
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
##
|
|
@@ -7,6 +7,8 @@ module Html2rss
|
|
|
7
7
|
class ContainerAssessor
|
|
8
8
|
# Microdata itemprop values treated as publish/update markers.
|
|
9
9
|
PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
|
|
10
|
+
# Letter/digit runs ({WordCount.runs}) beyond the title that make a container descriptive.
|
|
11
|
+
DESCRIPTIVE_RUNS = 8
|
|
10
12
|
|
|
11
13
|
# @param text_classifier [LinkDestination::TextClassifier]
|
|
12
14
|
# @param content_token_regexp [Regexp]
|
|
@@ -25,11 +27,12 @@ module Html2rss
|
|
|
25
27
|
# @param destination_facts [LinkDestination::DestinationFacts, nil]
|
|
26
28
|
# @return [Observation]
|
|
27
29
|
def call(container, selected_anchor, destination_facts:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
28
|
-
|
|
30
|
+
source = container.find(&:heading?) || selected_anchor
|
|
31
|
+
title = source ? source.visible_text.to_s.strip : ''
|
|
29
32
|
tokens = "#{container.attrs.class_attr} #{container.attrs.id}"
|
|
30
33
|
|
|
31
34
|
Observation.new(
|
|
32
|
-
|
|
35
|
+
title_runs: source ? source.run_count : 0,
|
|
33
36
|
path_length: destination_facts&.url&.path.to_s.length,
|
|
34
37
|
content_path: destination_facts&.content_path,
|
|
35
38
|
publish_marker: publish_marker?(container),
|
|
@@ -59,20 +62,16 @@ module Html2rss
|
|
|
59
62
|
end
|
|
60
63
|
|
|
61
64
|
def descriptive_context?(container_text, title)
|
|
62
|
-
snippet = container_text.to_s
|
|
63
|
-
snippet.length > 30 &&
|
|
65
|
+
snippet = without_title_prefix(container_text.to_s, title)
|
|
66
|
+
snippet.length > 30 && WordCount.runs(snippet, ceiling: DESCRIPTIVE_RUNS) == DESCRIPTIVE_RUNS
|
|
64
67
|
end
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
end
|
|
69
|
+
# Case-insensitive prefix strip compared by character count, so multi-character
|
|
70
|
+
# case folds (e.g. "ß" vs "ss") do not match; the title is then left in the snippet.
|
|
71
|
+
def without_title_prefix(text, title)
|
|
72
|
+
return text unless text[0, title.length].casecmp?(title)
|
|
71
73
|
|
|
72
|
-
|
|
73
|
-
count = 0
|
|
74
|
-
text.to_s.scan(/\p{Alnum}+/) { count += 1 }
|
|
75
|
-
count
|
|
74
|
+
text[title.length..]
|
|
76
75
|
end
|
|
77
76
|
end
|
|
78
77
|
end
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
SCORE_FLOOR = 0.0
|
|
12
12
|
|
|
13
13
|
FEATURE_IDS = Set[
|
|
14
|
-
:
|
|
14
|
+
:title_runs_ge3, :title_runs_ge7, :path_length_gt6, :content_path,
|
|
15
15
|
:publish_marker, :descriptive_context, :article_container, :content_tokens,
|
|
16
16
|
:non_content_utility_path, :utility_prefix_title_short, :shallow, :weak_container,
|
|
17
17
|
:recommended_title_non_content, :high_confidence_junk_path, :junk_tokens,
|
|
@@ -22,8 +22,8 @@ module Html2rss
|
|
|
22
22
|
|
|
23
23
|
# Quality feature triples: [feature_id, predicate, weight].
|
|
24
24
|
QUALITY_RULES = [
|
|
25
|
-
[:
|
|
26
|
-
[:
|
|
25
|
+
[:title_runs_ge3, ->(o) { o.title_runs >= 3 }, 40],
|
|
26
|
+
[:title_runs_ge7, ->(o) { o.title_runs >= 7 }, 15],
|
|
27
27
|
[:path_length_gt6, ->(o) { o.path_length > 6 }, 20],
|
|
28
28
|
[:content_path, lambda(&:content_path), 15],
|
|
29
29
|
[:publish_marker, lambda(&:publish_marker), 15],
|
|
@@ -35,7 +35,7 @@ module Html2rss
|
|
|
35
35
|
# Junk feature triples: [feature_id, predicate, weight].
|
|
36
36
|
JUNK_RULES = [
|
|
37
37
|
[:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
|
|
38
|
-
[:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.
|
|
38
|
+
[:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_runs <= 6 }, 15],
|
|
39
39
|
[:shallow, lambda(&:shallow), 10],
|
|
40
40
|
[:weak_container, ->(o) { !o.publish_marker && !o.descriptive_context }, 10],
|
|
41
41
|
[:recommended_title_non_content, ->(o) { o.recommended_title && !o.content_path }, 10],
|
|
@@ -46,9 +46,9 @@ module Html2rss
|
|
|
46
46
|
##
|
|
47
47
|
# @param id [Symbol]
|
|
48
48
|
# @return [Symbol]
|
|
49
|
-
# @raise [
|
|
49
|
+
# @raise [InvalidOption] when id is not in the closed set
|
|
50
50
|
def self.assert_feature_id!(id)
|
|
51
|
-
raise
|
|
51
|
+
raise InvalidOption, "unknown feature: #{id.inspect}" unless FEATURE_IDS.include?(id)
|
|
52
52
|
|
|
53
53
|
id
|
|
54
54
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module Scoring
|
|
5
5
|
Observation = Data.define(
|
|
6
|
-
:
|
|
6
|
+
:title_runs,
|
|
7
7
|
:path_length,
|
|
8
8
|
:content_path,
|
|
9
9
|
:publish_marker,
|
|
@@ -40,7 +40,7 @@ module Html2rss
|
|
|
40
40
|
when :lenient
|
|
41
41
|
(high_confidence_junk_path && weak_signals?) || chrome_title_junk?
|
|
42
42
|
else
|
|
43
|
-
raise
|
|
43
|
+
raise InvalidOption, "unknown hard_junk mode: #{mode.inspect}"
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -8,7 +8,12 @@ module Html2rss
|
|
|
8
8
|
# Does not emit the default +a[href]+ fallback — that remains Capture's YAML path only.
|
|
9
9
|
class SelectorCandidates # rubocop:disable Metrics/ClassLength -- ranking + field CSS stay co-located
|
|
10
10
|
# Empty candidate buckets (no discovered evidence).
|
|
11
|
-
EMPTY = {
|
|
11
|
+
EMPTY = {
|
|
12
|
+
items: [].freeze, title: [].freeze, link: [].freeze, published: [].freeze, candidates_by_item: [].freeze
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# How many top-ranked item candidates get their own field buckets in +:candidates_by_item+.
|
|
16
|
+
BY_ITEM_LIMIT = 5
|
|
12
17
|
|
|
13
18
|
# Ranking preference for items selector derivation kind (lower is better).
|
|
14
19
|
KIND_RANK = { shared_class: 0, unique_tag: 1, path: 2 }.freeze
|
|
@@ -22,8 +27,8 @@ module Html2rss
|
|
|
22
27
|
DEFAULT_MIN_MATCHES = 2
|
|
23
28
|
private_constant :DEFAULT_MIN_MATCHES
|
|
24
29
|
|
|
25
|
-
# Class token that identifies a non-heading title node.
|
|
26
|
-
TITLE_CLASS_PATTERN =
|
|
30
|
+
# Class token that identifies a non-heading title node. Owned by the extractor so both match the same nodes.
|
|
31
|
+
TITLE_CLASS_PATTERN = Html::SstArticleExtractor::TITLE_CLASS_PATTERN
|
|
27
32
|
private_constant :TITLE_CLASS_PATTERN
|
|
28
33
|
|
|
29
34
|
class << self
|
|
@@ -37,7 +42,8 @@ module Html2rss
|
|
|
37
42
|
# @param items_evidence [Array<Hash>] each with +:selector+, +:enhance+, +:strategy+,
|
|
38
43
|
# +:kind+, +:match_count+, and +:roots+ (SST nodes under the items selector)
|
|
39
44
|
# @param min_matches [Integer] minimum repeated field evidence across roots
|
|
40
|
-
# @return [Hash{Symbol=>Array<Hash>}] +:items+, +:title+, +:link+, +:published+
|
|
45
|
+
# @return [Hash{Symbol=>Array<Hash>}] +:items+, +:title+, +:link+, +:published+ (buckets for the
|
|
46
|
+
# top item) and +:candidates_by_item+ (per-item buckets for the top {BY_ITEM_LIMIT} items)
|
|
41
47
|
def call(items_evidence:, min_matches: DEFAULT_MIN_MATCHES)
|
|
42
48
|
new(items_evidence:, min_matches:).call
|
|
43
49
|
end
|
|
@@ -57,17 +63,22 @@ module Html2rss
|
|
|
57
63
|
items = rank_items(@items_evidence)
|
|
58
64
|
return EMPTY if items.empty?
|
|
59
65
|
|
|
60
|
-
|
|
66
|
+
by_item = items.first(BY_ITEM_LIMIT).map { |item| item_buckets(item) }
|
|
67
|
+
{ items:, **by_item.first.slice(:title, :link, :published), candidates_by_item: by_item }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def item_buckets(item)
|
|
73
|
+
roots = roots_for(item, @items_evidence)
|
|
61
74
|
{
|
|
62
|
-
|
|
75
|
+
selector: item.fetch(:selector),
|
|
63
76
|
title: field_bucket(roots, :title),
|
|
64
77
|
link: field_bucket(roots, :link),
|
|
65
78
|
published: field_bucket(roots, :published)
|
|
66
79
|
}
|
|
67
80
|
end
|
|
68
81
|
|
|
69
|
-
private
|
|
70
|
-
|
|
71
82
|
def rank_items(evidence)
|
|
72
83
|
evidence
|
|
73
84
|
.sort_by { |entry| item_rank_key(entry) }
|
|
@@ -11,7 +11,7 @@ YAML / MCP wire vocabulary is unchanged (`selectors`, `extractor`, `post_process
|
|
|
11
11
|
## Live flow
|
|
12
12
|
|
|
13
13
|
1. **Parse** — HTML stays on `Response#parsed_body`. JSON responses are converted once via `JsonXml` into an HTML5 fragment so CSS selectors still run.
|
|
14
|
-
2. **Items** — `items.selector` finds each card/node. Omitted, blank, and `Selectors::DEFAULT_ITEMS_SELECTOR` expand to `Html::Navigator::MAIN_ANCHOR_SELECTOR` (permalink anchors). Any other selector, including `a` and `.card`, is used as written. `Config.prepare_defaults` fills that default, with `enhance: true`, only when `selectors:` is present. `enhance: true` fills missing fields via `Html::ArticleExtractor` (list-card enrichment). Enhancement is skipped when title, url, description, published_at, enclosures, and categories are already populated.
|
|
14
|
+
2. **Items** — `items.selector` finds each card/node. Omitted, blank, and `Selectors::DEFAULT_ITEMS_SELECTOR` expand to `Html::Navigator::MAIN_ANCHOR_SELECTOR` (permalink anchors). Any other selector, including `a` and `.card`, is used as written. `Config.prepare_defaults` fills that default, with `enhance: true`, only when `selectors:` is present. `enhance: true` fills missing fields via `Html::ArticleExtractor` (list-card enrichment); an item with no link of its own gets no `url`, never a `page#id` fragment, and is left out of the feed (`Article#linked?`, the rule auto-source applies too). An item needs a resolvable link: configure a `url` selector when the items are not anchors or do not contain one. Enhancement is skipped when title, url, description, published_at, enclosures, and categories are already populated.
|
|
15
15
|
3. **Field dispatch** (private on `Selectors`) — for each selectable key (`title`, `url`, `enclosure`, `categories`, …):
|
|
16
16
|
- Regular fields → `Extractors.call` → optional `PostProcessors.call` chain
|
|
17
17
|
- Special keys → enclosure wrap, guid fan-out, multi-node categories
|
|
@@ -11,6 +11,9 @@ module Html2rss
|
|
|
11
11
|
# introspection SoT (+for+ / +expectation_for+); {SchemaExport} maps Ruby
|
|
12
12
|
# types to JSON Schema only.
|
|
13
13
|
class OptionSpec
|
|
14
|
+
# Raised when an extractor/post-processor +OPTIONS+ declares a type with no JSON Schema mapping.
|
|
15
|
+
class InvalidError < Html2rss::ContractError; end
|
|
16
|
+
|
|
14
17
|
# Maps Ruby option types to JSON Schema +type+ strings.
|
|
15
18
|
RUBY_TO_JSON_TYPE = {
|
|
16
19
|
String => 'string',
|
|
@@ -83,7 +86,7 @@ module Html2rss
|
|
|
83
86
|
when Array then ruby_type.map { |t| json_type_for(t) }
|
|
84
87
|
else
|
|
85
88
|
RUBY_TO_JSON_TYPE.fetch(ruby_type) do
|
|
86
|
-
raise
|
|
89
|
+
raise InvalidError, "unsupported OPTIONS type mapping for #{ruby_type}"
|
|
87
90
|
end
|
|
88
91
|
end
|
|
89
92
|
end
|
|
@@ -114,14 +114,6 @@ module Html2rss
|
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
attr_reader :value, :context
|
|
117
|
-
|
|
118
|
-
# Abstract method to be implemented by subclasses
|
|
119
|
-
#
|
|
120
|
-
# @return [Object] transformed value
|
|
121
|
-
# @raise [NotImplementedError] if not implemented in subclass
|
|
122
|
-
def call
|
|
123
|
-
raise NotImplementedError, 'You must implement the `call` method in the post processor'
|
|
124
|
-
end
|
|
125
117
|
end
|
|
126
118
|
end
|
|
127
119
|
end
|
|
@@ -70,7 +70,7 @@ module Html2rss
|
|
|
70
70
|
#
|
|
71
71
|
# @param string [String]
|
|
72
72
|
# @return [Regexp]
|
|
73
|
-
# @raise [
|
|
73
|
+
# @raise [InvalidOption] when +string+ is not a String, exceeds the character cap,
|
|
74
74
|
# contains nested quantifiers, or does not parse
|
|
75
75
|
def self.compiled_pattern(string)
|
|
76
76
|
compiled_patterns[string] ||= compile_regexp_string(string)
|
|
@@ -88,25 +88,25 @@ module Html2rss
|
|
|
88
88
|
# to maintain backwards compatibility before building the Regexp.
|
|
89
89
|
# Length is the character count of the authored string (the JSON Schema
|
|
90
90
|
# +maxLength+ unit), then the AST is walked for nested quantifiers,
|
|
91
|
-
# then +to_re+. Parser errors are re-raised as
|
|
91
|
+
# then +to_re+. Parser errors are re-raised as InvalidOption so
|
|
92
92
|
# validation and execution share one path.
|
|
93
93
|
#
|
|
94
94
|
# @param string [String]
|
|
95
95
|
# @return [Regexp]
|
|
96
|
-
# @raise [
|
|
96
|
+
# @raise [InvalidOption] when +string+ is not a String, exceeds {MAX_PATTERN_LENGTH},
|
|
97
97
|
# contains nested quantifiers, or does not parse
|
|
98
98
|
def self.compile_regexp_string(string)
|
|
99
|
-
raise
|
|
100
|
-
raise
|
|
99
|
+
raise InvalidOption, 'must be a string!' unless string.is_a?(String)
|
|
100
|
+
raise InvalidOption, "pattern exceeds #{MAX_PATTERN_LENGTH} characters" if string.length > MAX_PATTERN_LENGTH
|
|
101
101
|
|
|
102
102
|
source = regexp_source(string)
|
|
103
103
|
|
|
104
104
|
expression = Regexp::Parser.parse(source)
|
|
105
|
-
raise
|
|
105
|
+
raise InvalidOption, 'pattern contains nested quantifiers' if nested_quantifiers?(expression)
|
|
106
106
|
|
|
107
107
|
expression.to_re
|
|
108
108
|
rescue Regexp::Parser::Error => error
|
|
109
|
-
raise
|
|
109
|
+
raise InvalidOption, error.message, [], cause: nil
|
|
110
110
|
end
|
|
111
111
|
private_class_method :compile_regexp_string
|
|
112
112
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class Selectors
|
|
5
|
+
module PostProcessors
|
|
6
|
+
module HtmlTransformers
|
|
7
|
+
##
|
|
8
|
+
# Transformer that keeps only media a reader can play: an +http(s)+ +src+ on +<audio>+, +<video>+
|
|
9
|
+
# and +<source>+, made absolute; any other +src+ (missing, blank, +javascript:+, +data:+, unparsable)
|
|
10
|
+
# is removed. A +<source>+ left without one is dropped, and so is an +<audio>+ or +<video>+ with no
|
|
11
|
+
# playable +src+ of its own or on a child +<source>+ (no empty player shell).
|
|
12
|
+
class KeepPlayableMedia
|
|
13
|
+
# Elements that play a file named by +src+ (theirs or a child +<source>+).
|
|
14
|
+
PLAYERS = %w[audio video].freeze
|
|
15
|
+
|
|
16
|
+
# @param base_url [String, Html2rss::Url] base URL used to resolve relative sources
|
|
17
|
+
def initialize(base_url)
|
|
18
|
+
@base_url = base_url
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# @param node_name [String] node name currently being transformed
|
|
23
|
+
# @param node [Nokogiri::XML::Node] node currently being transformed
|
|
24
|
+
# @param _env [Hash] transformer context
|
|
25
|
+
# @option _env [Object] :_reserved reserved for transformer pipeline context
|
|
26
|
+
# @return [void]
|
|
27
|
+
def call(node_name:, node:, **_env)
|
|
28
|
+
if node_name == 'source'
|
|
29
|
+
node.unlink unless absolutize_src(node)
|
|
30
|
+
elsif PLAYERS.include?(node_name)
|
|
31
|
+
# Children are transformed after their parent, so child sources are only checked here.
|
|
32
|
+
playable = absolutize_src(node) || node.css('source').any? { Url.resolve_web(_1['src'], @base_url) }
|
|
33
|
+
node.unlink unless playable
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Rewrites +src+ to its absolute playable URL (Nokogiri stores its +to_s+) or removes it;
|
|
40
|
+
# returns the URL kept, if any.
|
|
41
|
+
def absolutize_src(node)
|
|
42
|
+
url = Url.resolve_web(node['src'], @base_url)
|
|
43
|
+
url ? node['src'] = url : node.remove_attribute('src')
|
|
44
|
+
url
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb
CHANGED
|
@@ -12,9 +12,7 @@ module Html2rss
|
|
|
12
12
|
URL_ELEMENTS_WITH_URL_ATTRIBUTE = {
|
|
13
13
|
'a' => :href, # Visible link
|
|
14
14
|
'img' => :src, # Visible image
|
|
15
|
-
'iframe' => :src
|
|
16
|
-
'audio' => :src, # Can show controls, so potentially visible
|
|
17
|
-
'video' => :src # Video player is visible
|
|
15
|
+
'iframe' => :src # Embedded frame (visible content)
|
|
18
16
|
}.freeze
|
|
19
17
|
|
|
20
18
|
# @param base_url [String, Html2rss::Url] base URL used to resolve relative links
|
|
@@ -63,14 +63,14 @@ module Html2rss
|
|
|
63
63
|
# @param _value [String] extracted time string (unused; type-checked via VALUE_TYPE)
|
|
64
64
|
# @param context [Selectors::StepEnv] must carry a usable +time_zone+
|
|
65
65
|
# @return [void]
|
|
66
|
-
# @raise [
|
|
66
|
+
# @raise [MissingOption] when time_zone is nil or empty
|
|
67
67
|
def self.validate_args!(_value, context)
|
|
68
68
|
time_zone_value = context.time_zone
|
|
69
69
|
|
|
70
|
-
raise
|
|
70
|
+
raise MissingOption, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.nil?
|
|
71
71
|
|
|
72
72
|
assert_type(time_zone_value, String, :time_zone, context:)
|
|
73
|
-
raise
|
|
73
|
+
raise MissingOption, 'time_zone cannot be nil or empty', [], cause: nil if time_zone_value.empty?
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
##
|