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,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
|
##
|
|
@@ -26,7 +26,7 @@ module Html2rss
|
|
|
26
26
|
# @yield [RequestService::Response] each page response
|
|
27
27
|
# @return [Enumerator] enumerator when no block is given
|
|
28
28
|
def each
|
|
29
|
-
return enum_for
|
|
29
|
+
return enum_for unless block_given?
|
|
30
30
|
|
|
31
31
|
yield initial_response
|
|
32
32
|
|
|
@@ -48,14 +48,14 @@ module Html2rss
|
|
|
48
48
|
|
|
49
49
|
# @return [Integer] configured maximum pages (always a positive Integer)
|
|
50
50
|
def max_pages
|
|
51
|
-
value = config
|
|
51
|
+
value = config[:max_pages]
|
|
52
52
|
value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_PAGES
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# @param next_url [Html2rss::Url, String, nil]
|
|
56
56
|
# @return [Boolean]
|
|
57
57
|
def follow_up_allowed?(next_url)
|
|
58
|
-
!next_url.
|
|
58
|
+
!next_url.to_s.empty? && !session.visited?(next_url)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# @param next_url [Html2rss::Url, String]
|
|
@@ -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
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# @param page_response [RequestService::Response]
|
|
12
12
|
# @param page_number [Integer]
|
|
13
13
|
# @return [String, nil]
|
|
14
|
-
def next_page_url(page_response, page_number:
|
|
14
|
+
def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
15
|
selector = config[:selector]
|
|
16
16
|
return nil if selector.nil? || selector.empty?
|
|
17
17
|
|
|
@@ -19,16 +19,16 @@ module Html2rss
|
|
|
19
19
|
return nil unless node
|
|
20
20
|
|
|
21
21
|
href = node['href'] || node.text
|
|
22
|
-
return nil if href.
|
|
22
|
+
return nil if href.strip.empty?
|
|
23
23
|
|
|
24
|
-
Html2rss::Url.from_relative(href
|
|
24
|
+
Html2rss::Url.from_relative(href, page_response.url)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# @param parsed_body [Nokogiri::HTML::Document]
|
|
28
28
|
# @param selector [String]
|
|
29
29
|
# @return [Nokogiri::XML::Node, nil]
|
|
30
30
|
def extract_node(parsed_body, selector)
|
|
31
|
-
parsed_body.at_css(selector)
|
|
31
|
+
parsed_body.at_css(selector)
|
|
32
32
|
rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
|
|
33
33
|
# Pure XPath like `descendant::a` fails LOOKS_LIKE_XPATH / at_css; fall back explicitly.
|
|
34
34
|
parsed_body.at_xpath(selector)
|
|
@@ -25,14 +25,8 @@ module Html2rss
|
|
|
25
25
|
# @return [Html2rss::Url]
|
|
26
26
|
def next_page_url(page_response, page_number:)
|
|
27
27
|
param = config.fetch(:param, DEFAULT_PARAM)
|
|
28
|
-
target_offset_val = target_offset_for(page_number)
|
|
29
28
|
url = Html2rss::Url.from_absolute(page_response.url)
|
|
30
|
-
|
|
31
|
-
if url.to_s.include?('{offset}')
|
|
32
|
-
Html2rss::Url.from_absolute(url.to_s.gsub('{offset}', target_offset_val.to_s))
|
|
33
|
-
else
|
|
34
|
-
url.with_query_values(url.query_values.merge(param.to_s => target_offset_val.to_s))
|
|
35
|
-
end
|
|
29
|
+
url.with_query_values(url.query_values.merge(param.to_s => target_offset_for(page_number).to_s))
|
|
36
30
|
end
|
|
37
31
|
|
|
38
32
|
# @param page_number [Integer]
|
|
@@ -11,7 +11,7 @@ module Html2rss
|
|
|
11
11
|
# @param page_response [RequestService::Response]
|
|
12
12
|
# @param page_number [Integer]
|
|
13
13
|
# @return [String, nil]
|
|
14
|
-
def next_page_url(page_response, page_number:
|
|
14
|
+
def next_page_url(page_response, page_number:) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
15
|
href = page_response.parsed_body.at_css('link[rel~="next"][href], a[rel~="next"][href]')&.[]('href')
|
|
16
16
|
return nil if href.nil? || href.empty?
|
|
17
17
|
|
|
@@ -25,14 +25,8 @@ module Html2rss
|
|
|
25
25
|
# @return [Html2rss::Url]
|
|
26
26
|
def next_page_url(page_response, page_number:)
|
|
27
27
|
param = config.fetch(:param, DEFAULT_PARAM)
|
|
28
|
-
target_page_val = target_page_for(page_number)
|
|
29
28
|
url = Html2rss::Url.from_absolute(page_response.url)
|
|
30
|
-
|
|
31
|
-
if url.to_s.include?('{page}')
|
|
32
|
-
Html2rss::Url.from_absolute(url.to_s.gsub('{page}', target_page_val.to_s))
|
|
33
|
-
else
|
|
34
|
-
url.with_query_values(url.query_values.merge(param.to_s => target_page_val.to_s))
|
|
35
|
-
end
|
|
29
|
+
url.with_query_values(url.query_values.merge(param.to_s => target_page_for(page_number).to_s))
|
|
36
30
|
end
|
|
37
31
|
|
|
38
32
|
# @param page_number [Integer]
|
|
@@ -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
|
|
@@ -91,21 +91,17 @@ module Html2rss
|
|
|
91
91
|
|
|
92
92
|
private
|
|
93
93
|
|
|
94
|
-
def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/
|
|
94
|
+
def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/AbcSize
|
|
95
95
|
facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
|
|
96
96
|
obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
|
|
97
97
|
return if obs.hard_junk?(mode: hard_junk_mode)
|
|
98
98
|
|
|
99
99
|
quality, quality_parts = apply_rules(QUALITY_RULES, obs)
|
|
100
100
|
junk, junk_parts = apply_rules(JUNK_RULES, obs)
|
|
101
|
-
|
|
101
|
+
RankedSegment.new(
|
|
102
102
|
segment:,
|
|
103
|
-
score:
|
|
104
|
-
|
|
105
|
-
quality:,
|
|
106
|
-
junk:,
|
|
107
|
-
breakdown: quality_parts.merge(junk_parts)
|
|
108
|
-
)
|
|
103
|
+
score: Score.new(composite: (quality - junk).to_f, quality: quality.to_f, junk: junk.to_f,
|
|
104
|
+
breakdown: quality_parts.merge(junk_parts).freeze)
|
|
109
105
|
)
|
|
110
106
|
end
|
|
111
107
|
|
|
@@ -116,30 +112,6 @@ module Html2rss
|
|
|
116
112
|
end
|
|
117
113
|
[parts.values.sum, parts]
|
|
118
114
|
end
|
|
119
|
-
|
|
120
|
-
def build_score(composite:, quality: nil, junk: nil, breakdown: nil)
|
|
121
|
-
raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
|
|
122
|
-
|
|
123
|
-
Score.new(
|
|
124
|
-
composite: composite.to_f,
|
|
125
|
-
quality: (quality.nil? ? composite : quality).to_f,
|
|
126
|
-
junk: (junk || 0).to_f,
|
|
127
|
-
breakdown: normalize_breakdown(breakdown)
|
|
128
|
-
)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def normalize_breakdown(breakdown)
|
|
132
|
-
return Score::EMPTY_BREAKDOWN if breakdown.nil?
|
|
133
|
-
|
|
134
|
-
breakdown.transform_keys { |id| self.class.assert_feature_id!(id) }.freeze
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def ranked(segment:, score:)
|
|
138
|
-
raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
|
|
139
|
-
raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
|
|
140
|
-
|
|
141
|
-
RankedSegment.new(segment:, score:)
|
|
142
|
-
end
|
|
143
115
|
end
|
|
144
116
|
end
|
|
145
117
|
end
|
|
@@ -24,7 +24,6 @@ module Html2rss
|
|
|
24
24
|
def initialize(base_url)
|
|
25
25
|
@base_url = base_url
|
|
26
26
|
@by_href = {}
|
|
27
|
-
@by_node = {}.compare_by_identity
|
|
28
27
|
@text_classifier = LinkDestination::TextClassifier.new
|
|
29
28
|
end
|
|
30
29
|
|
|
@@ -35,15 +34,11 @@ module Html2rss
|
|
|
35
34
|
# @param node_or_href [SST::Node, String, #to_s]
|
|
36
35
|
# @return [LinkDestination::DestinationFacts, nil]
|
|
37
36
|
def destination_facts(node_or_href)
|
|
38
|
-
return @by_node[node_or_href] if node_or_href.is_a?(SST::Node) && @by_node.key?(node_or_href)
|
|
39
|
-
|
|
40
37
|
href = extract_href(node_or_href)
|
|
41
38
|
return unless href
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
facts
|
|
46
|
-
rescue ArgumentError
|
|
40
|
+
@by_href[href] ||= build_facts(href)
|
|
41
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
47
42
|
nil
|
|
48
43
|
end
|
|
49
44
|
|
|
@@ -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
|
|
@@ -54,11 +54,9 @@ module Html2rss
|
|
|
54
54
|
##
|
|
55
55
|
# Retrieves and returns the normalized absolute URL.
|
|
56
56
|
#
|
|
57
|
-
# @return [Html2rss::Url, nil] The absolute URL.
|
|
57
|
+
# @return [Html2rss::Url, nil] The absolute URL, or nil when nothing matched or the +href+ is blank.
|
|
58
58
|
def call
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
Url.from_relative(@href, @base_url)
|
|
59
|
+
Url.from_relative(@href, @base_url) unless @href.strip.empty?
|
|
62
60
|
end
|
|
63
61
|
end
|
|
64
62
|
end
|
|
@@ -14,48 +14,47 @@ module Html2rss
|
|
|
14
14
|
array: ['<array>', '</array>']
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
class << self
|
|
18
|
+
##
|
|
19
|
+
# Converts the object to XML format.
|
|
20
|
+
#
|
|
21
|
+
# @param object [Object] any Ruby object (Hash, Array, String, Symbol, etc.)
|
|
22
|
+
# @return [String] representing the object in XML
|
|
23
|
+
def call(object)
|
|
24
|
+
object_to_xml(object).tap do |converted|
|
|
25
|
+
Html2rss::Log.debug("#{self}: converted object to XML (#{converted.bytesize} bytes)")
|
|
26
|
+
end
|
|
27
27
|
end
|
|
28
|
-
end
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def object_to_xml(object)
|
|
32
|
+
case object
|
|
33
|
+
when Hash
|
|
34
|
+
hash_to_xml(object)
|
|
35
|
+
when Array
|
|
36
|
+
array_to_xml(object)
|
|
37
|
+
else
|
|
38
|
+
CGI.escapeHTML(object.to_s)
|
|
39
|
+
end
|
|
38
40
|
end
|
|
39
|
-
end
|
|
40
|
-
private_class_method :object_to_xml
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
def hash_to_xml(object)
|
|
43
|
+
prefix, suffix = OBJECT_TO_XML_TAGS[:hash]
|
|
44
|
+
inner_xml = object.each_with_object(+'') do |(key, value), str|
|
|
45
|
+
str << "<#{key}>#{object_to_xml(value)}</#{key}>"
|
|
46
|
+
end
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
private_class_method :hash_to_xml
|
|
48
|
+
"#{prefix}#{inner_xml}#{suffix}"
|
|
49
|
+
end
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
def array_to_xml(object)
|
|
52
|
+
prefix, suffix = OBJECT_TO_XML_TAGS[:array]
|
|
53
|
+
inner_xml = object.each_with_object(+'') { |value, str| str << object_to_xml(value) }
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
"#{prefix}#{inner_xml}#{suffix}"
|
|
56
|
+
end
|
|
57
57
|
end
|
|
58
|
-
private_class_method :array_to_xml
|
|
59
58
|
end
|
|
60
59
|
end
|
|
61
60
|
end
|