adspower-client 1.0.14 → 1.0.16
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/adspower-client.gemspec +4 -2
- data/lib/adspower-client.rb +285 -22
- metadata +42 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0b196d617707b812d5e87b904ccd55beb1230522d8d5b2bc81c38ac390e19083
|
4
|
+
data.tar.gz: a5a2da96222347a99953c923a31fa34d73f9c5b1cbff0426cd31b00c095cd9a1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ea9fc969ed0823856448a27a66ad1bf1b96553d85fa710772abd7035449111f3e430f2bcead285b05c3bad3ad74f716620fb1f065b04e1a2d4a81351e3553767
|
7
|
+
data.tar.gz: 9b46991822d21c1ea0c2ac3f107a083fb0d77d7e776a9cfbb2a9ab79a19050cf74bf5e55078e7e16581a3df240d9265b00f83525a19a2c7950bbab88a1e561a6
|
data/adspower-client.gemspec
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'adspower-client'
|
3
|
-
s.version = '1.0.
|
4
|
-
s.date = '
|
3
|
+
s.version = '1.0.16'
|
4
|
+
s.date = '2025-07-27'
|
5
5
|
s.summary = "Ruby library for operating AdsPower API."
|
6
6
|
s.description = "Ruby library for operating AdsPower API."
|
7
7
|
s.authors = ["Leandro Daniel Sardi"]
|
@@ -19,6 +19,8 @@ Gem::Specification.new do |s|
|
|
19
19
|
s.add_runtime_dependency 'selenium-webdriver', '~> 4.10.0', '>= 4.10.0'
|
20
20
|
s.add_runtime_dependency 'watir', '~> 7.3.0', '>= 7.3.0'
|
21
21
|
#s.add_runtime_dependency 'sequel', '~> 5.75.0', '>= 5.75.0'
|
22
|
+
s.add_runtime_dependency 'fileutils', '~> 1.6.0', '>= 1.6.0'
|
22
23
|
s.add_runtime_dependency 'colorize', '~> 0.8.1', '>= 0.8.1'
|
23
24
|
s.add_runtime_dependency 'simple_cloud_logging', '~> 1.2.2', '>= 1.2.2'
|
25
|
+
s.add_runtime_dependency 'countries', '~> 7.1.1', '>= 7.1.1'
|
24
26
|
end
|
data/lib/adspower-client.rb
CHANGED
@@ -5,11 +5,22 @@ require 'blackstack-core'
|
|
5
5
|
require 'selenium-webdriver'
|
6
6
|
require 'watir'
|
7
7
|
require 'fileutils'
|
8
|
+
require 'countries'
|
9
|
+
|
10
|
+
class AdsPowerClient
|
11
|
+
CLOUD_API_BASE = 'https://api.adspower.com/v1'
|
12
|
+
|
13
|
+
# Constante generada en tiempo de ejecución:
|
14
|
+
COUNTRY_LANG = ISO3166::Country.all.each_with_object({}) do |country, h|
|
15
|
+
# El primer idioma oficial (ISO 639-1) que encuentre:
|
16
|
+
language_code = country.languages&.first || 'en'
|
17
|
+
# Construimos la etiqueta BCP47 Language-Region:
|
18
|
+
h[country.alpha2] = "#{language_code}-#{country.alpha2}"
|
19
|
+
end.freeze
|
8
20
|
|
9
|
-
class AdsPowerClient
|
10
21
|
# reference: https://localapi-doc-en.adspower.com/
|
11
22
|
# reference: https://localapi-doc-en.adspower.com/docs/Rdw7Iu
|
12
|
-
attr_accessor :key, :port, :server_log, :adspower_listener, :adspower_default_browser_version
|
23
|
+
attr_accessor :key, :port, :server_log, :adspower_listener, :adspower_default_browser_version, :cloud_token
|
13
24
|
|
14
25
|
# control over the drivers created, in order to not create the same driver twice and not generate memory leaks.
|
15
26
|
# reference: https://github.com/leandrosardi/adspower-client/issues/4
|
@@ -22,7 +33,12 @@ class AdsPowerClient
|
|
22
33
|
self.port = h[:port] || '50325'
|
23
34
|
self.server_log = h[:server_log] || '~/adspower-client.log'
|
24
35
|
self.adspower_listener = h[:adspower_listener] || 'http://127.0.0.1'
|
36
|
+
|
37
|
+
# DEPRECATED
|
25
38
|
self.adspower_default_browser_version = h[:adspower_default_browser_version] || '116'
|
39
|
+
|
40
|
+
# PENDING
|
41
|
+
self.cloud_token = h[:cloud_token]
|
26
42
|
end
|
27
43
|
|
28
44
|
# Acquire the lock
|
@@ -89,6 +105,54 @@ class AdsPowerClient
|
|
89
105
|
end
|
90
106
|
end
|
91
107
|
|
108
|
+
# Count current profiles (optionally filtered by group)
|
109
|
+
def profile_count(group_id: nil)
|
110
|
+
count = 0
|
111
|
+
page = 1
|
112
|
+
|
113
|
+
loop do
|
114
|
+
params = { page: page, limit: 100 }
|
115
|
+
params[:group_id] = group_id if group_id
|
116
|
+
url = "#{adspower_listener}:#{port}/api/v2/browser-profile/list"
|
117
|
+
res = BlackStack::Netting.call_post(url, params)
|
118
|
+
data = JSON.parse(res.body)
|
119
|
+
raise "Error listing profiles: #{data['msg']}" unless data['code'] == 0
|
120
|
+
|
121
|
+
list = data['data']['list']
|
122
|
+
count += list.size
|
123
|
+
break if list.size < 100
|
124
|
+
|
125
|
+
page += 1
|
126
|
+
end
|
127
|
+
|
128
|
+
count
|
129
|
+
end
|
130
|
+
|
131
|
+
# Return a hash with:
|
132
|
+
# • :limit ⇒ total profile slots allowed (-1 = unlimited)
|
133
|
+
# • :used ⇒ number of profiles currently created
|
134
|
+
# • :remaining ⇒ slots left (nil if unlimited)
|
135
|
+
# Fetch your real profile quota from the Cloud API
|
136
|
+
def cloud_profile_quota
|
137
|
+
uri = URI("#{CLOUD_API_BASE}/account/get_info")
|
138
|
+
req = Net::HTTP::Get.new(uri)
|
139
|
+
req['Authorization'] = "Bearer #{self.cloud_token}"
|
140
|
+
|
141
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
142
|
+
http.request(req)
|
143
|
+
end
|
144
|
+
data = JSON.parse(res.body)
|
145
|
+
raise "Cloud API error: #{data['msg']}" unless data['code'] == 0
|
146
|
+
|
147
|
+
allowed = data['data']['total_profiles_allowed'].to_i
|
148
|
+
used = data['data']['profiles_used'].to_i
|
149
|
+
remaining = allowed < 0 ? nil : (allowed - used)
|
150
|
+
|
151
|
+
{ limit: allowed,
|
152
|
+
used: used,
|
153
|
+
remaining: remaining }
|
154
|
+
end # cloud_profile_quota
|
155
|
+
|
92
156
|
# Create a new user profile via API call and return the ID of the created user.
|
93
157
|
def create
|
94
158
|
with_lock do
|
@@ -106,8 +170,194 @@ class AdsPowerClient
|
|
106
170
|
raise "Error: #{ret.to_s}" if ret['msg'].to_s.downcase != 'success'
|
107
171
|
ret['data']['id']
|
108
172
|
end
|
173
|
+
end # def create
|
174
|
+
|
175
|
+
|
176
|
+
# Lookup GeoIP gratuito (freegeoip.app) y parseo básico
|
177
|
+
def geolocate(ip)
|
178
|
+
uri = URI("https://freegeoip.app/json/#{ip}")
|
179
|
+
res = Net::HTTP.get(uri)
|
180
|
+
h = JSON.parse(res)
|
181
|
+
{
|
182
|
+
country_code: h["country_code"],
|
183
|
+
time_zone: h["time_zone"],
|
184
|
+
latitude: h["latitude"],
|
185
|
+
longitude: h["longitude"]
|
186
|
+
}
|
187
|
+
rescue
|
188
|
+
# Fallback genérico
|
189
|
+
{ country_code: "US", time_zone: "America/New_York", latitude: 38.9, longitude: -77.0 }
|
109
190
|
end
|
110
191
|
|
192
|
+
# Create a new desktop profile with:
|
193
|
+
# • name, proxy, fingerprint, etc (unchanged)
|
194
|
+
# • platform (e.g. "linkedin.com")
|
195
|
+
# • tabs (Array of URLs to open)
|
196
|
+
# • username / password / fakey for that platform
|
197
|
+
#
|
198
|
+
# @param name [String] the profile’s display name
|
199
|
+
# @param proxy_config [Hash] keys: :ip, :port, :user, :password, :proxy_soft (default 'other'), :proxy_type (default 'http')
|
200
|
+
# @param group_id [String] which AdsPower group to assign (default '0')
|
201
|
+
# @param browser_version [String] optional Chrome version to use (must match Chromedriver). Only applies if `fingerprint` is nil, as custom fingerprints override kernel settings.
|
202
|
+
# @param fingerprint [Hash, nil] optional fingerprint configuration. If not provided, a stealth-ready default is applied with DNS-over-HTTPS, spoofed WebGL/Canvas/audio, consistent User-Agent and locale, and hardening flags to minimize detection risks from tools like BrowserScan, Cloudflare, and Arkose Labs.
|
203
|
+
# @param platform [String] (optional) target site domain, e.g. 'linkedin.com'
|
204
|
+
# @param tabs [Array<String>] (optional) array of URLs to open on launch
|
205
|
+
# @param username [String] (optional) platform login username
|
206
|
+
# @param password [String] (optional) platform login password
|
207
|
+
# @param fakey [String,nil] optional 2FA key
|
208
|
+
# @return String the new profile’s ID
|
209
|
+
def create2(
|
210
|
+
name:,
|
211
|
+
proxy_config:,
|
212
|
+
group_id: '0',
|
213
|
+
browser_version: nil,
|
214
|
+
fingerprint: nil,
|
215
|
+
platform: '', # default: no platform
|
216
|
+
tabs: [], # default: no tabs to open
|
217
|
+
username: '', # default: no login
|
218
|
+
password: '', # default: no password
|
219
|
+
fakey: '' # leave blank if no 2FA
|
220
|
+
)
|
221
|
+
browser_version ||= adspower_default_browser_version
|
222
|
+
|
223
|
+
# 0) Resolve full Chrome version ─────────────────────────────
|
224
|
+
# Fetch the list of known-good Chrome versions and pick the highest
|
225
|
+
uri = URI('https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json')
|
226
|
+
resp = Net::HTTP.get_response(uri)
|
227
|
+
unless resp.is_a?(Net::HTTPSuccess)
|
228
|
+
raise "Error fetching Chrome versions: HTTP #{resp.code}"
|
229
|
+
end
|
230
|
+
listing = JSON.parse(resp.body)
|
231
|
+
versions = listing['versions'] || []
|
232
|
+
# find all entries matching the major.minor prefix
|
233
|
+
matches = versions.map { |v| v['version'] }
|
234
|
+
.select { |ver| ver.start_with?("#{browser_version}.") }
|
235
|
+
if matches.empty?
|
236
|
+
raise "Chrome version '#{browser_version}' not found in known-good versions list"
|
237
|
+
end
|
238
|
+
# pick the highest patch/build by semantic compare
|
239
|
+
full_version = matches
|
240
|
+
.map { |ver| ver.split('.').map(&:to_i) }
|
241
|
+
.max
|
242
|
+
.join('.')
|
243
|
+
|
244
|
+
# 1) Hacemos GeoIP sobre la IP del proxy
|
245
|
+
geo = geolocate(proxy_config[:ip])
|
246
|
+
lang = COUNTRY_LANG[geo[:country_code]] || "en-US"
|
247
|
+
screen_res = "1920_1080"
|
248
|
+
|
249
|
+
with_lock do
|
250
|
+
url = "#{adspower_listener}:#{port}/api/v2/browser-profile/create"
|
251
|
+
body = {
|
252
|
+
# ─── GENERAL & PROXY ─────────────────────────────
|
253
|
+
'name' => name,
|
254
|
+
'group_id' => group_id,
|
255
|
+
'user_proxy_config' => {
|
256
|
+
'proxy_soft' => proxy_config[:proxy_soft] || 'other',
|
257
|
+
'proxy_type' => proxy_config[:proxy_type] || 'socks5',
|
258
|
+
'proxy_host' => proxy_config[:ip],
|
259
|
+
'proxy_port' => proxy_config[:port].to_s,
|
260
|
+
'proxy_user' => proxy_config[:user],
|
261
|
+
'proxy_password' => proxy_config[:password],
|
262
|
+
|
263
|
+
# ─── FORCE ALL DNS THROUGH PROXY ─────────────────
|
264
|
+
# Avoid DNS-Leak
|
265
|
+
"proxy_dns": 1, # 1 = yes, 0 = no
|
266
|
+
"dns_servers": ["8.8.8.8","8.8.4.4"] # optional: your choice of DNS
|
267
|
+
},
|
268
|
+
|
269
|
+
# ─── PLATFORM ─────────────────────────────────────
|
270
|
+
'platform' => platform, # must be one of AdsPower’s supported “sites”
|
271
|
+
'tabs' => tabs, # array of URLs to open
|
272
|
+
'username' => username,
|
273
|
+
'password' => password,
|
274
|
+
'fakey' => fakey, # 2FA, if any
|
275
|
+
|
276
|
+
# ─── FINGERPRINT ──────────────────────────────────
|
277
|
+
"fingerprint_config" => fingerprint || {
|
278
|
+
|
279
|
+
# ─── 0) DNS Leak Prevention ───────────────────────────
|
280
|
+
# Even with “proxy_dns” forced on, a few ISPs will still
|
281
|
+
# silently intercept every UDP:53 out of your AdsPower VPS
|
282
|
+
# and shove it into their own resolver farm (the classic
|
283
|
+
# “transparent DNS proxy” attack that BrowserScan is warning you about).
|
284
|
+
#
|
285
|
+
# Because you refuse to hot-patch your Chrome via extra args or CDP,
|
286
|
+
# the only way to survive an ISP-level hijack is to push all name lookups
|
287
|
+
# into an encrypted channel that the ISP simply can’t touch: DNS-over-HTTPS (DoH).
|
288
|
+
#
|
289
|
+
# Here’s the minimal change you need to bake into your AdsPower profile at
|
290
|
+
# creation time so that every DNS query happens inside Chrome’s DoH stack:
|
291
|
+
#
|
292
|
+
"extra_launch_flags" => [
|
293
|
+
# === DNS over HTTPS only ===
|
294
|
+
"--enable-features=DnsOverHttps",
|
295
|
+
"--dns-over-https-mode=secure",
|
296
|
+
"--dns-over-https-templates=https://cloudflare-dns.com/dns-query",
|
297
|
+
"--disable-ipv6",
|
298
|
+
|
299
|
+
# === hide “Chrome is being controlled…” banner ===
|
300
|
+
#
|
301
|
+
# Even though you baked in the DoH flags under extra_launch_flags,
|
302
|
+
# you never told Chrome to hide its “automation” banners or black-hole
|
303
|
+
# all other DNS lookups — and BrowserScan still sees those UDP:53 calls
|
304
|
+
# leaking out.
|
305
|
+
#
|
306
|
+
# What you need is to push three more flags into your profile creation,
|
307
|
+
# and then attach with the exact same flags when Selenium hooks in.
|
308
|
+
#
|
309
|
+
"--disable-blink-features=AutomationControlled",
|
310
|
+
"--disable-infobars",
|
311
|
+
"--disable-features=TranslateUI", # optional but reduces tell-tale infobars
|
312
|
+
"--host-resolver-rules=MAP * 0.0.0.0,EXCLUDE localhost,EXCLUDE cloudflare-dns.com"
|
313
|
+
],
|
314
|
+
|
315
|
+
# ─── 1) Kernel & versión ───────────────────────────
|
316
|
+
"browser_kernel_config" => {
|
317
|
+
"version" => browser_version, # aquí usamos el parámetro
|
318
|
+
"type" => "chrome"
|
319
|
+
},
|
320
|
+
|
321
|
+
# ─── 2) Timezone & locale ──────────────────────────
|
322
|
+
"automatic_timezone" => "1",
|
323
|
+
#"timezone" => geo[:time_zone],
|
324
|
+
"language" => [ lang ],
|
325
|
+
|
326
|
+
# ─── 3) User-Agent coherente ───────────────────────
|
327
|
+
"ua_category" => "desktop",
|
328
|
+
'ua' => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/#{full_version} Safari/537.36",
|
329
|
+
"is_mobile" => false,
|
330
|
+
|
331
|
+
# ─── 4) Pantalla y plataforma ──────────────────────
|
332
|
+
# It turns out that “Based on User-Agent” is purely a UI setting
|
333
|
+
#"screen_resolution" => screen_res, "1920_1080"
|
334
|
+
"platform" => "Linux x86_64",
|
335
|
+
|
336
|
+
# ─── 5) Canvas & WebGL custom ─────────────────────
|
337
|
+
"canvas" => "1",
|
338
|
+
"webgl_image" => "1",
|
339
|
+
"webgl" => "0", # 0=deshabilitado, 2=modo custom, 3=modo random-match
|
340
|
+
"webgl_config" => {
|
341
|
+
"unmasked_vendor" => "Intel Inc.",
|
342
|
+
"unmasked_renderer" => "ANGLE (Intel, Mesa Intel(R) Xe Graphics (TGL GT2), OpenGL 4.6)",
|
343
|
+
"webgpu" => { "webgpu_switch" => "1" }
|
344
|
+
},
|
345
|
+
|
346
|
+
# ─── 6) Resto de ajustes ───────────────────────────
|
347
|
+
"webrtc" => "disabled", # WebRTC sí admite “disabled”
|
348
|
+
"flash" => "block", # Flash únicamente “allow” o “block”
|
349
|
+
"fonts" => [] # usar fonts por defecto
|
350
|
+
}
|
351
|
+
}
|
352
|
+
|
353
|
+
res = BlackStack::Netting.call_post(url, body)
|
354
|
+
ret = JSON.parse(res.body)
|
355
|
+
raise "Error creating profile: #{ret['msg']}" unless ret['code'] == 0
|
356
|
+
|
357
|
+
ret['data']['profile_id']
|
358
|
+
end
|
359
|
+
end # def create2
|
360
|
+
|
111
361
|
# Delete a user profile via API call.
|
112
362
|
def delete(id)
|
113
363
|
with_lock do
|
@@ -186,34 +436,47 @@ class AdsPowerClient
|
|
186
436
|
driver
|
187
437
|
end
|
188
438
|
|
189
|
-
# Attach to the existing browser session with Selenium WebDriver.
|
190
439
|
def driver2(id, headless: false, read_timeout: 180)
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
#
|
196
|
-
|
197
|
-
|
198
|
-
# Attach test execution to the existing browser
|
199
|
-
url = ret['data']['ws']['selenium']
|
440
|
+
return @@drivers[id] if @@drivers[id]
|
441
|
+
|
442
|
+
# 1) start the AdsPower profile / grab its WebSocket URL
|
443
|
+
data = start(id, headless)['data']
|
444
|
+
ws = data['ws']['selenium'] # e.g. "127.0.0.1:XXXXX"
|
445
|
+
|
446
|
+
# 2) attach with DevTools (no more excludeSwitches or caps!)
|
200
447
|
opts = Selenium::WebDriver::Chrome::Options.new
|
201
|
-
opts.
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
448
|
+
opts.debugger_address = ws
|
449
|
+
opts.add_argument('--headless') if headless
|
450
|
+
|
451
|
+
http = Selenium::WebDriver::Remote::Http::Default.new
|
452
|
+
http.read_timeout = read_timeout
|
453
|
+
|
454
|
+
driver = Selenium::WebDriver.for(:chrome, options: opts, http_client: http)
|
455
|
+
|
456
|
+
driver.execute_cdp(
|
457
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
458
|
+
source: <<~JS
|
459
|
+
// 1) remove any leftover cdc_… / webdriver hooks
|
460
|
+
for (const k of Object.getOwnPropertyNames(window)) {
|
461
|
+
if (k.startsWith('cdc_') || k.includes('webdriver')) {
|
462
|
+
try { delete window[k]; } catch(e){}
|
463
|
+
}
|
464
|
+
}
|
206
465
|
|
207
|
-
|
208
|
-
|
466
|
+
// 2) stub out window.chrome so Chrome-based detection thinks this is “normal” Chrome
|
467
|
+
window.chrome = { runtime: {} };
|
468
|
+
JS
|
469
|
+
)
|
209
470
|
|
210
|
-
# Save the driver
|
211
471
|
@@drivers[id] = driver
|
212
|
-
|
213
|
-
# Return the driver
|
214
472
|
driver
|
215
473
|
end
|
474
|
+
|
475
|
+
|
476
|
+
|
216
477
|
|
478
|
+
# DEPRECATED - Use Zyte instead of this method.
|
479
|
+
#
|
217
480
|
# Create a new profile, start the browser, visit a page, grab the HTML, and clean up.
|
218
481
|
def html(url)
|
219
482
|
ret = {
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: adspower-client
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.16
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Leandro Daniel Sardi
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2025-07-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: uri
|
@@ -130,6 +130,26 @@ dependencies:
|
|
130
130
|
- - ">="
|
131
131
|
- !ruby/object:Gem::Version
|
132
132
|
version: 7.3.0
|
133
|
+
- !ruby/object:Gem::Dependency
|
134
|
+
name: fileutils
|
135
|
+
requirement: !ruby/object:Gem::Requirement
|
136
|
+
requirements:
|
137
|
+
- - "~>"
|
138
|
+
- !ruby/object:Gem::Version
|
139
|
+
version: 1.6.0
|
140
|
+
- - ">="
|
141
|
+
- !ruby/object:Gem::Version
|
142
|
+
version: 1.6.0
|
143
|
+
type: :runtime
|
144
|
+
prerelease: false
|
145
|
+
version_requirements: !ruby/object:Gem::Requirement
|
146
|
+
requirements:
|
147
|
+
- - "~>"
|
148
|
+
- !ruby/object:Gem::Version
|
149
|
+
version: 1.6.0
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: 1.6.0
|
133
153
|
- !ruby/object:Gem::Dependency
|
134
154
|
name: colorize
|
135
155
|
requirement: !ruby/object:Gem::Requirement
|
@@ -170,6 +190,26 @@ dependencies:
|
|
170
190
|
- - ">="
|
171
191
|
- !ruby/object:Gem::Version
|
172
192
|
version: 1.2.2
|
193
|
+
- !ruby/object:Gem::Dependency
|
194
|
+
name: countries
|
195
|
+
requirement: !ruby/object:Gem::Requirement
|
196
|
+
requirements:
|
197
|
+
- - "~>"
|
198
|
+
- !ruby/object:Gem::Version
|
199
|
+
version: 7.1.1
|
200
|
+
- - ">="
|
201
|
+
- !ruby/object:Gem::Version
|
202
|
+
version: 7.1.1
|
203
|
+
type: :runtime
|
204
|
+
prerelease: false
|
205
|
+
version_requirements: !ruby/object:Gem::Requirement
|
206
|
+
requirements:
|
207
|
+
- - "~>"
|
208
|
+
- !ruby/object:Gem::Version
|
209
|
+
version: 7.1.1
|
210
|
+
- - ">="
|
211
|
+
- !ruby/object:Gem::Version
|
212
|
+
version: 7.1.1
|
173
213
|
description: Ruby library for operating AdsPower API.
|
174
214
|
email: leandro@connectionsphere.com
|
175
215
|
executables: []
|