shodanz 1.0.6 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +85 -56
- data/examples/async_honeypot_detector.rb +19 -0
- data/examples/async_host_search_example.rb +29 -0
- data/examples/async_stream_example.rb +33 -0
- data/examples/streaming_banner_product_stats.rb +3 -0
- data/lib/shodanz.rb +9 -2
- data/lib/shodanz/apis/exploits.rb +12 -31
- data/lib/shodanz/apis/rest.rb +50 -80
- data/lib/shodanz/apis/streaming.rb +44 -82
- data/lib/shodanz/apis/utils.rb +175 -0
- data/lib/shodanz/client.rb +88 -1
- data/lib/shodanz/errors.rb +29 -0
- data/lib/shodanz/version.rb +3 -1
- data/shodanz.gemspec +21 -18
- metadata +60 -27
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: edfee7e174978325331ceb16d7d7f19a1cd7f7cdb6731269cf8f3bc9d9fdcb31
|
4
|
+
data.tar.gz: d89a612b5e3e90aa0ef9a288cf9650875f9a0031bf936d94b82d45852fb887ba
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3f9e3e19895688ad861517883165a0b06b965f89c28854d73bec521b7a0430ebd5394519aaf4fef5e5397c9c31520bff32eed9cbeed336f4c3932bf34b1c4f09
|
7
|
+
data.tar.gz: '09326a94bb100c9abe45715fc0430f4f8dedbfffbba359346b268d17b4c8523fbf3795c93cd054ed4b68af98e1166857385c8a6df5b66278d0fea56164bea6cc'
|
data/README.md
CHANGED
@@ -1,11 +1,10 @@
|
|
1
1
|
# Shodanz
|
2
2
|
|
3
|
-
A modern Ruby [gem](https://rubygems.org/) for [Shodan](https://www.shodan.io/), the world's first search engine for Internet-connected devices.
|
3
|
+
A modern, async Ruby [gem](https://rubygems.org/) for [Shodan](https://www.shodan.io/), the world's first search engine for Internet-connected devices.
|
4
4
|
|
5
5
|
## Installation
|
6
6
|
|
7
7
|
$ gem install shodanz
|
8
|
-
|
9
8
|
## Usage
|
10
9
|
|
11
10
|
```ruby
|
@@ -13,7 +12,37 @@ require "shodanz"
|
|
13
12
|
|
14
13
|
client = Shodanz.client.new(key: "YOUR_API_KEY")
|
15
14
|
```
|
16
|
-
> You can also set the `SHODAN_API_KEY` environment variable instead of passing the API key as an argument when creating a client.
|
15
|
+
> **NOTE:** You can also set the `SHODAN_API_KEY` environment variable instead of passing the API key as an argument when creating a client.
|
16
|
+
|
17
|
+
### Optional Async Support
|
18
|
+
|
19
|
+
Shodanz utilizes [async](https://github.com/socketry/async) to provide asyncronous IO. This doesn't break any existing scripts using Shodanz, but now offers even more flexibility to write more awesome things, like this asyncronous honeypot detector:
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
require 'async'
|
23
|
+
require 'shodanz'
|
24
|
+
|
25
|
+
client = Shodanz.client.new
|
26
|
+
|
27
|
+
# Asyncronously stream live banner info from shodan
|
28
|
+
# and check the IP addresses against the expierimental
|
29
|
+
# honeypot scoring service to find potential honeypots.
|
30
|
+
client.streaming_api.banners do |banner|
|
31
|
+
if ip = banner['ip_str']
|
32
|
+
Async do
|
33
|
+
score = client.rest_api.honeypot_score(ip).wait
|
34
|
+
puts "#{ip} has a #{score * 100}% chance of being a honeypot"
|
35
|
+
rescue Shodanz::Errors::RateLimited
|
36
|
+
sleep rand
|
37
|
+
retry
|
38
|
+
rescue # any other errors
|
39
|
+
next
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
> **Note:** To run any Shodanz method asyncronously, simply wrap it in a `Async { ... }` block. To wait for any other async operation to finnish in the block, call `.wait` on it.
|
17
46
|
|
18
47
|
## REST API
|
19
48
|
|
@@ -28,9 +57,9 @@ Search'n for stuff, are 'ya?
|
|
28
57
|
Returns all services that have been found on the given host IP.
|
29
58
|
|
30
59
|
```ruby
|
31
|
-
client.
|
32
|
-
client.
|
33
|
-
client.
|
60
|
+
client.host("8.8.8.8") # Default
|
61
|
+
client.host("8.8.8.8", history: true) # All historical banners should be returned.
|
62
|
+
client.host("8.8.8.8", minify: true) # Only return the list of ports and the general host information, no banners.
|
34
63
|
```
|
35
64
|
|
36
65
|
#### Host Search
|
@@ -38,12 +67,12 @@ client.rest_api.host("8.8.8.8", minify: true) # Only return the list of ports a
|
|
38
67
|
Search Shodan using the same query syntax as the website and use facets to get summary information for different properties.
|
39
68
|
|
40
69
|
```ruby
|
41
|
-
client.
|
42
|
-
client.
|
43
|
-
client.
|
44
|
-
client.
|
45
|
-
client.
|
46
|
-
client.
|
70
|
+
client.host_search("mongodb")
|
71
|
+
client.host_search("nginx")
|
72
|
+
client.host_search("apache", after: "1/12/16")
|
73
|
+
client.host_search("ssh", port: 22, page: 1)
|
74
|
+
client.host_search("ssh", port: 22, page: 2)
|
75
|
+
client.host_search("ftp", port: 21, facets: { link: "Ethernet or modem" })
|
47
76
|
```
|
48
77
|
|
49
78
|
#### Search Shodan without Results
|
@@ -51,12 +80,12 @@ client.rest_api.host_search("ftp", port: 21, facets: { link: "Ethernet or modem"
|
|
51
80
|
This method behaves identical to `host_search` with the only difference that this method does not return any host results, it only returns the total number of results that matched the query and any facet information that was requested. As a result this method does not consume query credits.
|
52
81
|
|
53
82
|
```ruby
|
54
|
-
client.
|
55
|
-
client.
|
56
|
-
client.
|
57
|
-
client.
|
58
|
-
client.
|
59
|
-
client.
|
83
|
+
client.host_count("apache")
|
84
|
+
client.host_count("apache", country: "US")
|
85
|
+
client.host_count("apache", country: "US", state: "MI")
|
86
|
+
client.host_count("apache", country: "US", state: "MI", city: "Detroit")
|
87
|
+
client.host_count("nginx", facets: { country: 5 })
|
88
|
+
client.host_count("apache", facets: { country: 5 })
|
60
89
|
```
|
61
90
|
|
62
91
|
#### Scan Targets
|
@@ -64,7 +93,7 @@ client.rest_api.host_count("apache", facets: { country: 5 })
|
|
64
93
|
Use this method to request Shodan to crawl an IP or netblock.
|
65
94
|
|
66
95
|
```ruby
|
67
|
-
client.
|
96
|
+
client.scan("8.8.8.8")
|
68
97
|
```
|
69
98
|
|
70
99
|
#### Crawl Internet for Port
|
@@ -74,7 +103,7 @@ Use this method to request Shodan to crawl the Internet for a specific port.
|
|
74
103
|
This method is restricted to security researchers and companies with a Shodan Data license. To apply for access to this method as a researcher, please email `jmath@shodan.io` with information about your project. Access is restricted to prevent abuse.
|
75
104
|
|
76
105
|
```ruby
|
77
|
-
client.
|
106
|
+
client.crawl_for(port: 80, protocol: "http")
|
78
107
|
```
|
79
108
|
|
80
109
|
#### List Community Queries
|
@@ -82,12 +111,12 @@ client.rest_api.crawl_for(port: 80, protocol: "http")
|
|
82
111
|
Use this method to obtain a list of search queries that users have saved in Shodan.
|
83
112
|
|
84
113
|
```ruby
|
85
|
-
client.
|
86
|
-
client.
|
87
|
-
client.
|
88
|
-
client.
|
89
|
-
client.
|
90
|
-
client.
|
114
|
+
client.community_queries
|
115
|
+
client.community_queries(page: 2)
|
116
|
+
client.community_queries(sort: "votes")
|
117
|
+
client.community_queries(sort: "votes", page: 2)
|
118
|
+
client.community_queries(order: "asc")
|
119
|
+
client.community_queries(order: "desc")
|
91
120
|
```
|
92
121
|
|
93
122
|
#### Search Community Queries
|
@@ -95,8 +124,8 @@ client.rest_api.community_queries(order: "desc")
|
|
95
124
|
Use this method to search the directory of search queries that users have saved in Shodan.
|
96
125
|
|
97
126
|
```ruby
|
98
|
-
client.
|
99
|
-
client.
|
127
|
+
client.search_for_community_query("the best")
|
128
|
+
client.search_for_community_query("the best", page: 2)
|
100
129
|
```
|
101
130
|
|
102
131
|
#### Popular Community Query Tags
|
@@ -104,8 +133,8 @@ client.rest_api.search_for_community_query("the best", page: 2)
|
|
104
133
|
Use this method to obtain a list of popular tags for the saved search queries in Shodan.
|
105
134
|
|
106
135
|
```ruby
|
107
|
-
client.
|
108
|
-
client.
|
136
|
+
client.popular_query_tags
|
137
|
+
client.popular_query_tags(20)
|
109
138
|
```
|
110
139
|
|
111
140
|
#### Protocols
|
@@ -113,7 +142,7 @@ client.rest_api.popular_query_tags(20)
|
|
113
142
|
This method returns an object containing all the protocols that can be used when launching an Internet scan.
|
114
143
|
|
115
144
|
```ruby
|
116
|
-
client.
|
145
|
+
client.protocols
|
117
146
|
```
|
118
147
|
|
119
148
|
#### Ports
|
@@ -121,7 +150,7 @@ client.rest_api.protocols
|
|
121
150
|
This method returns a list of port numbers that the Shodan crawlers are looking for.
|
122
151
|
|
123
152
|
```ruby
|
124
|
-
client.
|
153
|
+
client.ports
|
125
154
|
```
|
126
155
|
|
127
156
|
#### Account Profile
|
@@ -129,7 +158,7 @@ client.rest_api.ports
|
|
129
158
|
Returns information about the Shodan account linked to this API key.
|
130
159
|
|
131
160
|
```ruby
|
132
|
-
client.
|
161
|
+
client.profile
|
133
162
|
```
|
134
163
|
|
135
164
|
#### DNS Lookup
|
@@ -137,8 +166,8 @@ client.rest_api.profile
|
|
137
166
|
Look up the IP address for the provided list of hostnames.
|
138
167
|
|
139
168
|
```ruby
|
140
|
-
client.
|
141
|
-
client.
|
169
|
+
client.resolve("google.com")
|
170
|
+
client.resolve("google.com", "bing.com")
|
142
171
|
```
|
143
172
|
|
144
173
|
#### Reverse DNS Lookup
|
@@ -146,8 +175,8 @@ client.rest_api.resolve("google.com", "bing.com")
|
|
146
175
|
Look up the hostnames that have been defined for the given list of IP addresses.
|
147
176
|
|
148
177
|
```ruby
|
149
|
-
client.
|
150
|
-
client.
|
178
|
+
client.reverse_lookup("74.125.227.230")
|
179
|
+
client.reverse_lookup("74.125.227.230", "204.79.197.200")
|
151
180
|
```
|
152
181
|
|
153
182
|
#### HTTP Headers
|
@@ -155,7 +184,7 @@ client.rest_api.reverse_lookup("74.125.227.230", "204.79.197.200")
|
|
155
184
|
Shows the HTTP headers that your client sends when connecting to a webserver.
|
156
185
|
|
157
186
|
```ruby
|
158
|
-
client.
|
187
|
+
client.http_headers
|
159
188
|
```
|
160
189
|
|
161
190
|
#### Your IP Address
|
@@ -163,7 +192,7 @@ client.rest_api.http_headers
|
|
163
192
|
Get your current IP address as seen from the Internet.
|
164
193
|
|
165
194
|
```ruby
|
166
|
-
client.
|
195
|
+
client.my_ip
|
167
196
|
```
|
168
197
|
|
169
198
|
#### Honeypot Score
|
@@ -171,13 +200,13 @@ client.rest_api.my_ip
|
|
171
200
|
Calculates a honeypot probability score ranging from 0 (not a honeypot) to 1.0 (is a honeypot).
|
172
201
|
|
173
202
|
```ruby
|
174
|
-
client.
|
203
|
+
client.honeypot_score('8.8.8.8')
|
175
204
|
```
|
176
205
|
|
177
206
|
#### API Plan Information
|
178
207
|
|
179
208
|
```ruby
|
180
|
-
client.
|
209
|
+
client.info
|
181
210
|
```
|
182
211
|
|
183
212
|
### Streaming API
|
@@ -188,7 +217,7 @@ The Streaming API is an HTTP-based service that returns a real-time stream of da
|
|
188
217
|
This stream provides ALL of the data that Shodan collects. Use this stream if you need access to everything and/ or want to store your own Shodan database locally. If you only care about specific ports, please use the Ports stream.
|
189
218
|
|
190
219
|
```ruby
|
191
|
-
client.
|
220
|
+
client.banners do |data|
|
192
221
|
# do something with banner data
|
193
222
|
puts data
|
194
223
|
end
|
@@ -199,7 +228,7 @@ end
|
|
199
228
|
This stream provides a filtered, bandwidth-saving view of the Banners stream in case you are only interested in devices located in certain ASNs.
|
200
229
|
|
201
230
|
```ruby
|
202
|
-
client.
|
231
|
+
client.banners_within_asns(3303, 32475) do |data|
|
203
232
|
# do something with banner data
|
204
233
|
puts data
|
205
234
|
end
|
@@ -210,18 +239,18 @@ end
|
|
210
239
|
This stream provides a filtered, bandwidth-saving view of the Banners stream in case you are only interested in devices located in certain countries.
|
211
240
|
|
212
241
|
```ruby
|
213
|
-
client.
|
242
|
+
client.banners_within_countries("DE", "US", "JP") do |data|
|
214
243
|
# do something with banner data
|
215
244
|
puts data
|
216
245
|
end
|
217
246
|
```
|
218
247
|
|
219
|
-
#### Banners Filtered by Ports
|
248
|
+
#### Banners Filtered by Ports
|
220
249
|
|
221
250
|
Only returns banner data for the list of specified ports. This stream provides a filtered, bandwidth-saving view of the Banners stream in case you are only interested in a specific list of ports.
|
222
251
|
|
223
252
|
```ruby
|
224
|
-
client.
|
253
|
+
client.banners_on_ports(21, 22, 80) do |data|
|
225
254
|
# do something with banner data
|
226
255
|
puts data
|
227
256
|
end
|
@@ -232,7 +261,7 @@ end
|
|
232
261
|
Subscribe to banners discovered on all IP ranges described in the network alerts.
|
233
262
|
|
234
263
|
```ruby
|
235
|
-
client.
|
264
|
+
client.alerts do |data|
|
236
265
|
# do something with banner data
|
237
266
|
puts data
|
238
267
|
end
|
@@ -243,7 +272,7 @@ end
|
|
243
272
|
Subscribe to banners discovered on the IP range defined in a specific network alert.
|
244
273
|
|
245
274
|
```ruby
|
246
|
-
client.
|
275
|
+
client.alert("HKVGAIRWD79Z7W2T") do |data|
|
247
276
|
# do something with banner data
|
248
277
|
puts data
|
249
278
|
end
|
@@ -258,10 +287,10 @@ The Exploits API provides access to several exploit/ vulnerability data sources.
|
|
258
287
|
Search across a variety of data sources for exploits and use facets to get summary information.
|
259
288
|
|
260
289
|
```ruby
|
261
|
-
client.
|
262
|
-
client.
|
263
|
-
client.
|
264
|
-
client.
|
290
|
+
client.search("python") # Search for Snek vulns.
|
291
|
+
client.search(post: 22) # Port number for the affected service if the exploit is remote.
|
292
|
+
client.search(type: "shellcode") # A category of exploit to search for.
|
293
|
+
client.search(osvdb: "100007") # Open Source Vulnerability Database ID for the exploit.
|
265
294
|
```
|
266
295
|
|
267
296
|
#### Count
|
@@ -269,10 +298,10 @@ client.exploits_api.search(osvdb: "100007") # Open Source Vulnerability Dat
|
|
269
298
|
This method behaves identical to the Exploits API `search` method with the difference that it doesn't return any results.
|
270
299
|
|
271
300
|
```ruby
|
272
|
-
client.
|
273
|
-
client.
|
274
|
-
client.
|
275
|
-
client.
|
301
|
+
client.count("python") # Count Snek vulns.
|
302
|
+
client.count(port: 22) # Port number for the affected service if the exploit is remote.
|
303
|
+
client.count(type: "shellcode") # A category of exploit to search for.
|
304
|
+
client.count(osvdb: "100007") # Open Source Vulnerability Database ID for the exploit.
|
276
305
|
```
|
277
306
|
|
278
307
|
## License
|
@@ -0,0 +1,19 @@
|
|
1
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
2
|
+
require 'async'
|
3
|
+
require 'shodanz'
|
4
|
+
|
5
|
+
client = Shodanz.client.new
|
6
|
+
|
7
|
+
client.streaming_api.banners do |banner|
|
8
|
+
if ip = banner['ip_str']
|
9
|
+
Async do
|
10
|
+
score = client.rest_api.honeypot_score(ip).wait
|
11
|
+
puts "#{ip} has a #{score * 100}% chance of being a honeypot"
|
12
|
+
rescue Shodanz::Errors::RateLimited
|
13
|
+
sleep rand
|
14
|
+
retry
|
15
|
+
rescue # any other errors
|
16
|
+
next
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
|
2
|
+
require 'async'
|
3
|
+
require 'shodanz'
|
4
|
+
|
5
|
+
client = Shodanz.client.new
|
6
|
+
|
7
|
+
webservers = ['apache', 'nginx', 'caddy', 'lighttpd', 'cherokee']
|
8
|
+
|
9
|
+
# we can use methods sequentially
|
10
|
+
started = Time.now.sec
|
11
|
+
webservers.each do |webserver|
|
12
|
+
# make HTTP request
|
13
|
+
client.rest_api.host_search(webserver)
|
14
|
+
# print webserver to STDOUT
|
15
|
+
puts webserver
|
16
|
+
end
|
17
|
+
puts "Sequential took #{Time.now.sec - started} seconds"
|
18
|
+
|
19
|
+
# we can also use methods asyncronously
|
20
|
+
started = Time.now.sec
|
21
|
+
Async do
|
22
|
+
webservers.each do |webserver|
|
23
|
+
# make HTTP request
|
24
|
+
client.rest_api.host_search(webserver)
|
25
|
+
# print webserver to STDOUT
|
26
|
+
puts webserver
|
27
|
+
end
|
28
|
+
end
|
29
|
+
puts "Asyncronous took #{Time.now.sec - started} seconds"
|
@@ -0,0 +1,33 @@
|
|
1
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
|
2
|
+
require 'pry'
|
3
|
+
require 'async'
|
4
|
+
require 'shodanz'
|
5
|
+
|
6
|
+
client = Shodanz.client.new
|
7
|
+
|
8
|
+
stats = Hash.new(0)
|
9
|
+
|
10
|
+
ports = [21, 22, 80, 443]
|
11
|
+
services = ['ftp', 'ssh', 'http', 'https']
|
12
|
+
|
13
|
+
ports_with_service_names = ports.zip(services)
|
14
|
+
|
15
|
+
Async do
|
16
|
+
# collect banners for ports
|
17
|
+
ports_with_service_names.each do |port, service|
|
18
|
+
client.streaming_api.banners_on_port(port) do |banner|
|
19
|
+
if ip = banner['ip_str']
|
20
|
+
Async do
|
21
|
+
resp = client.rest_api.honeypot_score(ip).wait
|
22
|
+
binding.pry if resp.nil?
|
23
|
+
puts "#{ip} has a #{resp *100}% chance of being a honeypot"
|
24
|
+
rescue Shodanz::Errors::RateLimited
|
25
|
+
sleep 1
|
26
|
+
retry
|
27
|
+
rescue => error
|
28
|
+
binding.pry
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
data/lib/shodanz.rb
CHANGED
@@ -1,9 +1,16 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'async'
|
5
|
+
require 'async/http/internet'
|
3
6
|
require 'shodanz/version'
|
7
|
+
require 'shodanz/errors'
|
4
8
|
require 'shodanz/api'
|
5
9
|
require 'shodanz/client'
|
6
10
|
|
11
|
+
# disable async logs by default
|
12
|
+
Async.logger.level = 4
|
13
|
+
|
7
14
|
# Shodanz is a modern Ruby gem for Shodan, the world's
|
8
15
|
# first search engine for Internet-connected devices.
|
9
16
|
module Shodanz
|
@@ -1,3 +1,7 @@
|
|
1
|
+
require_relative 'utils.rb'
|
2
|
+
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
1
5
|
module Shodanz
|
2
6
|
module API
|
3
7
|
# The Exploits API provides access to several exploit
|
@@ -9,16 +13,21 @@ module Shodanz
|
|
9
13
|
#
|
10
14
|
# @author Kent 'picat' Gruber
|
11
15
|
class Exploits
|
16
|
+
include Shodanz::API::Utils
|
17
|
+
|
12
18
|
# @return [String]
|
13
19
|
attr_accessor :key
|
14
20
|
|
15
21
|
# The path to the REST API endpoint.
|
16
|
-
URL = 'https://exploits.shodan.io/api/'
|
22
|
+
URL = 'https://exploits.shodan.io/api/'
|
17
23
|
|
18
24
|
# @param key [String] SHODAN API key, defaulted to
|
19
25
|
# the *SHODAN_API_KEY* enviroment variable.
|
20
26
|
def initialize(key: ENV['SHODAN_API_KEY'])
|
21
|
-
|
27
|
+
@url = URL
|
28
|
+
@internet = Async::HTTP::Internet.new
|
29
|
+
self.key = key
|
30
|
+
|
22
31
|
warn 'No key has been found or provided!' unless key?
|
23
32
|
end
|
24
33
|
|
@@ -26,6 +35,7 @@ module Shodanz
|
|
26
35
|
# @return [String]
|
27
36
|
def key?
|
28
37
|
return true if @key
|
38
|
+
|
29
39
|
false
|
30
40
|
end
|
31
41
|
|
@@ -54,35 +64,6 @@ module Shodanz
|
|
54
64
|
params[:page] = page
|
55
65
|
get('count', params.merge(facets))
|
56
66
|
end
|
57
|
-
|
58
|
-
# Perform a direct GET HTTP request to the REST API.
|
59
|
-
def get(path, **params)
|
60
|
-
resp = Unirest.get "#{URL}#{path}?key=#{@key}", parameters: params
|
61
|
-
if resp.code != 200 && resp.body.key?('error')
|
62
|
-
raise resp.body['error']
|
63
|
-
end
|
64
|
-
resp.body
|
65
|
-
end
|
66
|
-
|
67
|
-
private
|
68
|
-
|
69
|
-
def turn_into_query(params)
|
70
|
-
filters = params.reject { |key, _| key == :query }
|
71
|
-
filters.each do |key, value|
|
72
|
-
params[:query] << " #{key}:#{value}"
|
73
|
-
end
|
74
|
-
params.select { |key, _| key == :query }
|
75
|
-
end
|
76
|
-
|
77
|
-
def turn_into_facets(facets)
|
78
|
-
filters = facets.reject { |key, _| key == :facets }
|
79
|
-
facets[:facets] = []
|
80
|
-
filters.each do |key, value|
|
81
|
-
facets[:facets] << "#{key}:#{value}"
|
82
|
-
end
|
83
|
-
facets[:facets] = facets[:facets].join(',')
|
84
|
-
facets.select { |key, _| key == :facets }
|
85
|
-
end
|
86
67
|
end
|
87
68
|
end
|
88
69
|
end
|