html2rss 0.24.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
module Scraper
|
|
6
|
+
class JsonState
|
|
7
|
+
# Identifies arrays that look like collections of article hashes.
|
|
8
|
+
module CandidateDetector
|
|
9
|
+
# Preferred keys when extracting title-like values from state payloads.
|
|
10
|
+
TITLE_KEYS = %i[title headline name text].freeze
|
|
11
|
+
# Preferred keys when extracting URL-like values from state payloads.
|
|
12
|
+
URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param document [Hash, Array, Object] candidate document node
|
|
17
|
+
# @return [Boolean] whether the node contains article-like arrays
|
|
18
|
+
def candidate_array?(document)
|
|
19
|
+
case document
|
|
20
|
+
when Array
|
|
21
|
+
return true if array_of_articles?(document)
|
|
22
|
+
|
|
23
|
+
document.any? { traversable_candidate?(_1) }
|
|
24
|
+
when Hash then document.each_value.any? { candidate_array?(_1) }
|
|
25
|
+
else false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param value [Hash, Array, Object] candidate nested value
|
|
30
|
+
# @return [Boolean] whether nested value should be traversed for article candidates
|
|
31
|
+
def traversable_candidate?(value)
|
|
32
|
+
case value
|
|
33
|
+
when Array, Hash then candidate_array?(value)
|
|
34
|
+
else false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param array [Array<Object>] candidate list of entries
|
|
39
|
+
# @return [Boolean] whether array includes hash entries with title and URL fields
|
|
40
|
+
def array_of_articles?(array)
|
|
41
|
+
array.any? do |element|
|
|
42
|
+
next unless element.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
title_from(element) && url_from(element)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param object [Hash] article candidate object
|
|
49
|
+
# @return [Object, nil] detected title-like value
|
|
50
|
+
def title_from(object)
|
|
51
|
+
ValueFinder.fetch(object, TITLE_KEYS)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param object [Hash] article candidate object
|
|
55
|
+
# @return [Object, nil] detected URL-like value
|
|
56
|
+
def url_from(object)
|
|
57
|
+
ValueFinder.fetch(object, URL_KEYS)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class AutoSource
|
|
7
|
+
module Scraper
|
|
8
|
+
class JsonState
|
|
9
|
+
# Scans DOM nodes for JSON payloads containing article data.
|
|
10
|
+
module DocumentScanner # rubocop:disable Metrics/ModuleLength
|
|
11
|
+
# Selector for JSON-only script tags.
|
|
12
|
+
JSON_SCRIPT_SELECTOR = 'script[type="application/json"]'
|
|
13
|
+
# Regex patterns for known global JavaScript state assignments.
|
|
14
|
+
GLOBAL_ASSIGNMENT_PATTERNS = [
|
|
15
|
+
/(?:window|self|globalThis)\.__NEXT_DATA__\s*=\s*/m,
|
|
16
|
+
/(?:window|self|globalThis)\.__NUXT__\s*=\s*/m,
|
|
17
|
+
/(?:window|self|globalThis)\.STATE\s*=\s*/m,
|
|
18
|
+
/(?:window|self|globalThis)\.__REDUX_STATE__\s*=\s*/m,
|
|
19
|
+
/(?:window|self|globalThis)\.__PRELOADED_STATE__\s*=\s*/m,
|
|
20
|
+
/(?:window|self|globalThis)\.__APOLLO_STATE__\s*=\s*/m,
|
|
21
|
+
/(?:window|self|globalThis)\.__remixContext\s*=\s*/m,
|
|
22
|
+
/(?:window|self|globalThis)\.__sveltekit_data\s*=\s*/m,
|
|
23
|
+
/(?:window|self|globalThis)\.GATSBY_STATE\s*=\s*/m,
|
|
24
|
+
/(?:window|self|globalThis)\.__ember_meta\s*=\s*/m,
|
|
25
|
+
/(?:window|self|globalThis)\.angular\s*=\s*/m
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Combined regex for faster matching of global assignments.
|
|
29
|
+
GLOBAL_ASSIGNMENT_REGEXP = Regexp.union(GLOBAL_ASSIGNMENT_PATTERNS).freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
34
|
+
# @return [Array<Hash, Array>] parsed JSON documents discovered in scripts
|
|
35
|
+
def json_documents(parsed_body)
|
|
36
|
+
# Use identity-based cache to avoid double-parsing of the same document.
|
|
37
|
+
# WeakMap allows the Nokogiri Document (key) to be garbage collected.
|
|
38
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
39
|
+
(@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||=
|
|
40
|
+
script_documents(parsed_body) + assignment_documents(parsed_body)
|
|
41
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
45
|
+
# @return [Array<Hash, Array>] JSON documents extracted from JSON script tags
|
|
46
|
+
def script_documents(parsed_body)
|
|
47
|
+
parsed_body.css(JSON_SCRIPT_SELECTOR).filter_map { parse_json(_1.text) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
51
|
+
# @return [Array<Hash, Array>] JSON documents extracted from global assignments
|
|
52
|
+
def assignment_documents(parsed_body)
|
|
53
|
+
parsed_body.css('script').filter_map { parse_assignment(_1.text) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param text [String] script text that may contain a global assignment
|
|
57
|
+
# @return [Hash, Array, nil] parsed assignment payload when available
|
|
58
|
+
def parse_assignment(text)
|
|
59
|
+
payload = assignment_payload(text)
|
|
60
|
+
parse_json(payload) if payload
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param text [String] script text to inspect for known assignment patterns
|
|
64
|
+
# @return [String, nil] extracted JSON-like assignment payload
|
|
65
|
+
def assignment_payload(text)
|
|
66
|
+
trimmed = text.to_s.strip
|
|
67
|
+
return if trimmed.empty?
|
|
68
|
+
return unless trimmed.match?(GLOBAL_ASSIGNMENT_REGEXP)
|
|
69
|
+
|
|
70
|
+
payload = trimmed.sub(GLOBAL_ASSIGNMENT_REGEXP, '')
|
|
71
|
+
extract_assignment_payload(payload)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param text [String] text potentially containing JSON-like payloads
|
|
75
|
+
# @return [String, nil] normalized assignment payload
|
|
76
|
+
def extract_assignment_payload(text)
|
|
77
|
+
extract_json_block(text) || text
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param text [String] text potentially containing JSON blocks
|
|
81
|
+
# @return [String, nil] extracted JSON block spanning balanced brackets
|
|
82
|
+
def extract_json_block(text)
|
|
83
|
+
start_index = text.index(/[\[{]/)
|
|
84
|
+
return unless start_index
|
|
85
|
+
|
|
86
|
+
stop_index = scan_for_json_end(text, start_index)
|
|
87
|
+
text[start_index..stop_index] if stop_index
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param text [String] text starting with a JSON object/array opening token
|
|
91
|
+
# @param start_index [Integer] index where JSON-like content starts
|
|
92
|
+
# @return [Integer, nil] index where the balanced JSON payload ends
|
|
93
|
+
def scan_for_json_end(text, start_index) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
94
|
+
stack = []
|
|
95
|
+
in_string = false
|
|
96
|
+
escape = false
|
|
97
|
+
|
|
98
|
+
i = start_index
|
|
99
|
+
len = text.length
|
|
100
|
+
while i < len
|
|
101
|
+
char = text[i]
|
|
102
|
+
|
|
103
|
+
if in_string
|
|
104
|
+
if escape
|
|
105
|
+
escape = false
|
|
106
|
+
elsif char == '\\'
|
|
107
|
+
escape = true
|
|
108
|
+
elsif char == '"'
|
|
109
|
+
in_string = false
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
case char
|
|
113
|
+
when '"' then in_string = true
|
|
114
|
+
when '{' then stack << '}'
|
|
115
|
+
when '[' then stack << ']'
|
|
116
|
+
when '}', ']'
|
|
117
|
+
expected = stack.pop
|
|
118
|
+
return i if expected == char && stack.empty?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
i += 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param payload [String, nil] JSON payload to parse
|
|
128
|
+
# @return [Hash, Array, nil] parsed payload or nil when parsing fails
|
|
129
|
+
def parse_json(payload)
|
|
130
|
+
return unless payload
|
|
131
|
+
|
|
132
|
+
JSON.parse(payload, symbolize_names: true)
|
|
133
|
+
rescue JSON::ParserError => error
|
|
134
|
+
parse_js_object(payload, error)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param payload [String] JavaScript object-literal payload
|
|
138
|
+
# @param _original_error [JSON::ParserError] original JSON parse error
|
|
139
|
+
# @return [Hash, Array, nil] parsed payload after JavaScript coercion
|
|
140
|
+
def parse_js_object(payload, _original_error)
|
|
141
|
+
coerced = coerce_javascript_object(payload)
|
|
142
|
+
return unless coerced
|
|
143
|
+
|
|
144
|
+
# Some sites emit JavaScript object literals (unquoted keys, trailing commas).
|
|
145
|
+
# Coerce those payloads into valid JSON so we keep the same parsing pipeline.
|
|
146
|
+
JSON.parse(coerced, symbolize_names: true)
|
|
147
|
+
rescue JSON::ParserError => error
|
|
148
|
+
Html2rss::Log.debug("JsonState: failed to parse coerced JavaScript object (#{error.message})")
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @param payload [String] JavaScript object-literal payload
|
|
153
|
+
# @return [String] JSON-compatible payload string
|
|
154
|
+
def coerce_javascript_object(payload)
|
|
155
|
+
string = payload.dup
|
|
156
|
+
|
|
157
|
+
# KISS approach: mutate common JS literal quirks instead of a full parser.
|
|
158
|
+
strip_trailing_commas(quote_unquoted_keys(string))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @param jsonish [String] JSON-like string with potentially unquoted keys
|
|
162
|
+
# @return [String] payload with unquoted object keys quoted
|
|
163
|
+
def quote_unquoted_keys(jsonish)
|
|
164
|
+
jsonish.gsub(/(?<prefix>\A\s*|[{,\[]\s*)(?<key>[A-Za-z_]\w*)(?<suffix>\s*:)/) do
|
|
165
|
+
captures = Regexp.last_match.named_captures(symbolize_names: true)
|
|
166
|
+
"#{captures[:prefix]}\"#{captures[:key]}\"#{captures[:suffix]}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @param jsonish [String] JSON-like string with potential trailing commas
|
|
171
|
+
# @return [String] payload without trailing commas before closing tokens
|
|
172
|
+
def strip_trailing_commas(jsonish)
|
|
173
|
+
jsonish.gsub(/,(\s*[\]}])/, '\1')
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
module Scraper
|
|
6
|
+
class JsonState
|
|
7
|
+
# Retrieves values from heterogeneous objects by probing multiple keys.
|
|
8
|
+
module ValueFinder
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @param object [Hash, Array] candidate container traversed during key lookup
|
|
12
|
+
# @param keys [Array<Symbol>] keys to probe in order
|
|
13
|
+
# @return [Object, nil] first matching value
|
|
14
|
+
def fetch(object, keys)
|
|
15
|
+
case object
|
|
16
|
+
when Hash then fetch_from_hash(object, keys)
|
|
17
|
+
when Array then fetch_from_array(object, keys)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param hash [Hash] hash candidate traversed during key lookup
|
|
22
|
+
# @param keys [Array<Symbol>] keys to probe in order
|
|
23
|
+
# @return [Object, nil] first matching value from hash or nested metadata
|
|
24
|
+
def fetch_from_hash(hash, keys)
|
|
25
|
+
keys.each do |key|
|
|
26
|
+
return hash[key] if hash.key?(key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
fetch_nested(hash[:attributes], keys) ||
|
|
30
|
+
fetch_nested(hash[:data], keys)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param array [Array] array whose entries may contain target keys
|
|
34
|
+
# @param keys [Array<Symbol>] keys to probe in order
|
|
35
|
+
# @return [Object, nil] first matching value from array entries
|
|
36
|
+
def fetch_from_array(array, keys)
|
|
37
|
+
array.each do |entry|
|
|
38
|
+
result = fetch(entry, keys)
|
|
39
|
+
return result if result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param value [Hash, Array, nil] nested value to recurse into
|
|
46
|
+
# @param keys [Array<Symbol>] keys to probe in order
|
|
47
|
+
# @return [Object, nil] matching nested value
|
|
48
|
+
def fetch_nested(value, keys)
|
|
49
|
+
fetch(value, keys) if value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|