dataforseo-ruby 0.3.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bd182bce70ec48794ea1e065277330eb1c86a25b2decd936ea7658273ba0d6d6
4
+ data.tar.gz: 20abf4e0a70019910f45b9df3999d922d7177370363439d6c93c3b4146aa9884
5
+ SHA512:
6
+ metadata.gz: 2fbd2611d29d93cf391b1b81f460009a83d0453911199dfaeb93350d717b526934c276ee4939c11ebd2597510c1f5eeda33e1e1cf85cc610f0ef00a69029380f
7
+ data.tar.gz: 16e8bbf39e81b771e2c75b467b7ec6027580f806d36666a9e27e63657dc79457c4a9e893c9fb97feb39e1bbed77a77f7e634fb2114a8e1035e0fc709635d63cf
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.3.1 — 2026-10-24
4
+
5
+ - First RubyGems release; README installs from RubyGems and lists the organic queue and TaskNotReady.
6
+
7
+ ## 0.3.0 — 2026-10-22
8
+
9
+ - google_organic_task_post returns task-level rejections in tasks[] instead of raising
10
+ (accepted tasks are already billed) and yields each batch as it is posted.
11
+
12
+ ## 0.2.0 — 2026-10-22
13
+
14
+ - Google organic Standard queue: google_organic_task_post (accepts 20100) and
15
+ google_organic_task_get (advanced, GET); TaskNotReady for 40601/40602.
16
+ - HttpTransport sends GET when the payload is nil.
17
+
18
+ ## 0.1.1 — 2026-09-13
19
+
20
+ - Non-object JSON error bodies are classified by HTTP status instead of raising TypeError/NoMethodError.
21
+
22
+ ## 0.1.0 — 2026-09-13
23
+
24
+ - Client for DataForSEO Live endpoints with typed errors and slicing.
25
+ - Typed methods: google_ads_search_volume, bulk_traffic_estimation, bulk_ranks,
26
+ bulk_spam_score, backlinks_summary.
27
+ - DataForSeo::Testing::FakeTransport.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Anton Kopylov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # dataforseo-ruby
2
+
3
+ Unofficial, dependency-free Ruby client for [DataForSEO](https://dataforseo.com) Live endpoints
4
+ and the Google organic Standard queue.
5
+
6
+ ```ruby
7
+ gem "dataforseo-ruby"
8
+ ```
9
+
10
+ ```ruby
11
+ client = DataForSeo::Client.new(login: ENV["DATAFORSEO_LOGIN"], password: ENV["DATAFORSEO_PASSWORD"])
12
+
13
+ response = client.google_ads_search_volume(keywords: ["reverse image search"], language_code: "en")
14
+ response.items # => [{"keyword" => "reverse image search", "search_volume" => 1500000, ...}]
15
+ response.cost # => 0.09
16
+ ```
17
+
18
+ `Client.new` also takes `open_timeout:` (10s), `read_timeout:` (120s) and `batch_size:` (1000).
19
+
20
+ ## Typed methods
21
+
22
+ | Method | Endpoint | Rows |
23
+ |---|---|---|
24
+ | `google_ads_search_volume(keywords:, language_code:, location_code: nil, search_partners: false)` | keywords_data/google_ads/search_volume/live | `result[]` |
25
+ | `bulk_traffic_estimation(targets:, location_name:, language_code:)` | dataforseo_labs/google/bulk_traffic_estimation/live | `result[].items[]` |
26
+ | `bulk_ranks(targets:, rank_scale:)` | backlinks/bulk_ranks/live | `result[].items[]` |
27
+ | `bulk_spam_score(targets:)` | backlinks/bulk_spam_score/live | `result[].items[]` |
28
+ | `backlinks_summary(target:, include_subdomains: true)` | backlinks/summary/live | `result[]` |
29
+ | `google_organic_task_post(tasks:)` | serp/google/organic/task_post | `tasks[]` (ids) |
30
+ | `google_organic_task_get(id:)` | serp/google/organic/task_get/advanced/{id} | `result[].items[]` |
31
+
32
+ Array methods send 1,000 inputs per request. Pass a block to receive each slice's
33
+ `Response` (with `#requested` holding only that slice) before the next request is
34
+ made, so paid results can be saved as they arrive. Rows come back as the API sent
35
+ them; nulls stay `nil` and absent targets stay absent.
36
+
37
+ `client.post(path, tasks)` reaches other **Live** endpoints. Of the task-based
38
+ endpoints (`20100` task created, results by GET) only Google organic is typed.
39
+
40
+ ## Errors
41
+
42
+ Every transport, protocol or API failure is a `DataForSeo::Error`:
43
+
44
+ - `ConnectionError` — timeouts, DNS, TLS, resets, unparseable bodies
45
+ - `ApiError` (`#status_code`, `#status_message`, `#path`, `#task_id`, `#http_status`)
46
+ - `AuthenticationError` (40100, HTTP 401)
47
+ - `PaymentRequired` (40200, 40210, HTTP 402)
48
+ - `RateLimitError` (40202, 40209, HTTP 429)
49
+ - `TransientError` (50301, 50303, 50401, HTTP 5xx)
50
+ - `TaskNotReady` (40601, 40602)
51
+
52
+ The client does not retry or pace. Know before you call:
53
+
54
+ - Google Ads Live endpoints allow 12 requests per minute per account.
55
+ - Google Ads keywords: at most 80 characters and 10 words (raises `ArgumentError`);
56
+ some symbols and emoji are rejected by the API and fail the whole request;
57
+ similar keywords in one request may share a combined volume.
58
+ - One malformed target voids a `bulk_spam_score` request (0 items, 0 cost).
59
+ - A timeout after the API accepted a request can still be charged; retrying pays again.
60
+
61
+ ## Testing
62
+
63
+ ```ruby
64
+ require "dataforseo/testing"
65
+
66
+ transport = DataForSeo::Testing::FakeTransport.new(
67
+ DataForSeo::Testing.ok_body(result: [{ "keyword" => "x", "search_volume" => 10 }])
68
+ )
69
+ client = DataForSeo::Client.new(login: "x", password: "y", transport:)
70
+ transport.calls # => [[path, payload], ...]
71
+ ```
72
+
73
+ A queued `Exception` instance is raised instead of returned.
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataForSeo
4
+ module GoogleAds
5
+ MAX_KEYWORD_LENGTH = 80
6
+ MAX_KEYWORD_WORDS = 10
7
+
8
+ def self.validate!(keywords)
9
+ bad = keywords.find { it.length > MAX_KEYWORD_LENGTH || it.split.size > MAX_KEYWORD_WORDS }
10
+ raise ArgumentError, "keyword exceeds Google Ads limits (80 chars, 10 words): #{bad.inspect}" if bad
11
+ end
12
+ end
13
+
14
+ class Client
15
+ OPEN_TIMEOUT = 10
16
+ # seo's bulk_traffic_estimation took 34.6s for 98 targets; a slice holds 1,000.
17
+ READ_TIMEOUT = 120
18
+ BATCH_SIZE = 1000
19
+ OK = 20000
20
+
21
+ RESULT = ->(task) { Array(task["result"]) }
22
+ ITEMS = ->(task) { Array(task["result"]).flat_map { Array(it["items"]) } }
23
+
24
+ def initialize(login:, password:, transport: nil, open_timeout: OPEN_TIMEOUT,
25
+ read_timeout: READ_TIMEOUT, batch_size: BATCH_SIZE)
26
+ @transport = transport || HttpTransport.new(login:, password:, open_timeout:, read_timeout:)
27
+ @batch_size = batch_size
28
+ end
29
+
30
+ # A task_post's 20100 "created" raises unless the caller accepts it; accept: nil checks no task.
31
+ def post(path, tasks, requested: [], extract: RESULT, accept: [OK])
32
+ body = @transport.call(path, tasks)
33
+ check!(body, path)
34
+ response_tasks = Array(body["tasks"])
35
+ if response_tasks.size != tasks.size
36
+ raise ApiError.new("expected #{tasks.size} task(s), got #{response_tasks.size}", path:)
37
+ end
38
+ response_tasks.each { check!(it, path, task_id: it["id"], accept:) } if accept
39
+
40
+ Response.new(path:, cost: body["cost"], tasks: response_tasks, items: response_tasks.flat_map(&extract), requested:)
41
+ end
42
+
43
+ def get(path, extract: RESULT)
44
+ body = @transport.call(path, nil)
45
+ check!(body, path)
46
+ response_tasks = Array(body["tasks"])
47
+ response_tasks.each { check!(it, path, task_id: it["id"]) }
48
+
49
+ Response.new(path:, cost: body["cost"], tasks: response_tasks, items: response_tasks.flat_map(&extract), requested: [])
50
+ end
51
+
52
+ SEARCH_VOLUME_PATH = "/v3/keywords_data/google_ads/search_volume/live"
53
+ TRAFFIC_PATH = "/v3/dataforseo_labs/google/bulk_traffic_estimation/live"
54
+ RANKS_PATH = "/v3/backlinks/bulk_ranks/live"
55
+ SPAM_PATH = "/v3/backlinks/bulk_spam_score/live"
56
+ SUMMARY_PATH = "/v3/backlinks/summary/live"
57
+ ORGANIC_TASK_POST_PATH = "/v3/serp/google/organic/task_post"
58
+ ORGANIC_TASK_GET_PATH = "/v3/serp/google/organic/task_get/advanced"
59
+ TASK_CREATED = 20100
60
+ TASK_POST_BATCH = 100
61
+
62
+ # No location means worldwide. Google Ads Live allows 12 requests/minute per
63
+ # account; pacing is the caller's.
64
+ def google_ads_search_volume(keywords:, language_code:, location_code: nil, search_partners: false, &block)
65
+ GoogleAds.validate!(keywords)
66
+ sliced(SEARCH_VOLUME_PATH, keywords, RESULT, block) do |slice|
67
+ task = { "keywords" => slice, "language_code" => language_code, "search_partners" => search_partners }
68
+ task["location_code"] = location_code if location_code
69
+ task
70
+ end
71
+ end
72
+
73
+ def bulk_traffic_estimation(targets:, location_name:, language_code:, &block)
74
+ sliced(TRAFFIC_PATH, targets, ITEMS, block) do |slice|
75
+ { "targets" => slice, "location_name" => location_name, "language_code" => language_code }
76
+ end
77
+ end
78
+
79
+ # rank_scale has no default on purpose.
80
+ def bulk_ranks(targets:, rank_scale:, &block)
81
+ sliced(RANKS_PATH, targets, ITEMS, block) { |slice| { "targets" => slice, "rank_scale" => rank_scale } }
82
+ end
83
+
84
+ # Sends no location/language: the endpoint rejects them (40501). One malformed
85
+ # target voids the whole slice.
86
+ def bulk_spam_score(targets:, &block)
87
+ sliced(SPAM_PATH, targets, ITEMS, block) { |slice| { "targets" => slice } }
88
+ end
89
+
90
+ def backlinks_summary(target:, include_subdomains: true)
91
+ post(SUMMARY_PATH, [{ "target" => target, "include_subdomains" => include_subdomains }],
92
+ requested: [target], extract: RESULT)
93
+ end
94
+
95
+ # Standard queue: results arrive via google_organic_task_get, usually within minutes.
96
+ # Tasks are billed on acceptance, so a rejected one comes back in tasks[] rather than
97
+ # raising, and each batch is yielded before the next is sent.
98
+ def google_organic_task_post(tasks:, &block)
99
+ responses = tasks.each_slice(TASK_POST_BATCH).map do |slice|
100
+ post(ORGANIC_TASK_POST_PATH, slice, accept: nil).tap { block&.call(it) }
101
+ end
102
+ Response.combine(responses, path: ORGANIC_TASK_POST_PATH, requested: [])
103
+ end
104
+
105
+ # Raises TaskNotReady while the task is still queued.
106
+ def google_organic_task_get(id:)
107
+ get("#{ORGANIC_TASK_GET_PATH}/#{id}", extract: ITEMS)
108
+ end
109
+
110
+ private
111
+
112
+ def check!(envelope, path, task_id: nil, accept: [OK])
113
+ return if accept.include?(envelope["status_code"])
114
+
115
+ raise DataForSeo.api_error(status_code: envelope["status_code"], status_message: envelope["status_message"],
116
+ path:, task_id:)
117
+ end
118
+
119
+ def sliced(path, inputs, extract, block)
120
+ responses = inputs.each_slice(@batch_size).map do |slice|
121
+ post(path, [yield(slice)], requested: slice, extract:).tap { block&.call(it) }
122
+ end
123
+ Response.combine(responses, path:, requested: inputs)
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataForSeo
4
+ # Every transport, protocol or API failure. A caller's retry_on names this
5
+ # (or a subclass) and nothing else, so nothing may escape as its own class.
6
+ class Error < StandardError; end
7
+
8
+ class ConnectionError < Error; end
9
+
10
+ class ApiError < Error
11
+ attr_reader :status_code, :status_message, :path, :task_id, :http_status
12
+
13
+ def initialize(message = nil, status_code: nil, status_message: nil, path: nil, task_id: nil, http_status: nil)
14
+ @status_code = status_code
15
+ @status_message = status_message
16
+ @path = path
17
+ @task_id = task_id
18
+ @http_status = http_status
19
+ head = ["DataForSEO", status_code || (http_status && "HTTP #{http_status}"), "on #{path}"].compact.join(" ")
20
+ super(message || (status_message ? "#{head}: #{status_message}" : head))
21
+ end
22
+ end
23
+
24
+ class AuthenticationError < ApiError; end
25
+ class PaymentRequired < ApiError; end
26
+ class RateLimitError < ApiError; end
27
+ class TransientError < ApiError; end
28
+ class TaskNotReady < ApiError; end
29
+
30
+ # https://docs.dataforseo.com/v3/appendix/errors/
31
+ API_ERRORS = {
32
+ 40100 => AuthenticationError,
33
+ 40200 => PaymentRequired, 40210 => PaymentRequired,
34
+ 40202 => RateLimitError, 40209 => RateLimitError,
35
+ 50301 => TransientError, 50303 => TransientError, 50401 => TransientError,
36
+ 40601 => TaskNotReady, 40602 => TaskNotReady
37
+ }.freeze
38
+
39
+ HTTP_ERRORS = { 401 => AuthenticationError, 402 => PaymentRequired, 429 => RateLimitError }.freeze
40
+
41
+ def self.api_error(status_code:, status_message:, path:, task_id: nil)
42
+ API_ERRORS.fetch(status_code, ApiError)
43
+ .new(status_code:, status_message:, path:, task_id:)
44
+ end
45
+
46
+ def self.http_error(http_status:, path:, status_code: nil, status_message: nil)
47
+ klass = HTTP_ERRORS.fetch(http_status) { http_status >= 500 ? TransientError : ApiError }
48
+ klass.new(http_status:, path:, status_code:, status_message:)
49
+ end
50
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "timeout"
7
+ require "zlib"
8
+
9
+ module DataForSeo
10
+ class HttpTransport
11
+ HOST = "api.dataforseo.com"
12
+
13
+ # Timeout::Error covers Net::Open/Read/WriteTimeout; Net::HTTP requests gzip
14
+ # and doesn't rescue inflate errors itself, hence Zlib::Error.
15
+ NETWORK_ERRORS = [
16
+ Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError, EOFError,
17
+ Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error
18
+ ].freeze
19
+
20
+ def initialize(login:, password:, open_timeout:, read_timeout:)
21
+ @login = login
22
+ @password = password
23
+ @open_timeout = open_timeout
24
+ @read_timeout = read_timeout
25
+ end
26
+
27
+ def call(path, payload)
28
+ request = payload.nil? ? Net::HTTP::Get.new(path) : Net::HTTP::Post.new(path, "Content-Type" => "application/json")
29
+ request.basic_auth(@login, @password)
30
+ request.body = JSON.generate(payload) unless payload.nil?
31
+
32
+ response = Net::HTTP.start(HOST, 443, use_ssl: true,
33
+ open_timeout: @open_timeout, read_timeout: @read_timeout) { it.request(request) }
34
+ parse(path, response)
35
+ rescue *NETWORK_ERRORS => e
36
+ raise ConnectionError, "#{e.class}: #{e.message}"
37
+ end
38
+
39
+ private
40
+
41
+ # HTTP status first: some failures arrive as a non-2xx with an HTML body,
42
+ # and parsing that first would report a credentials problem as a network one.
43
+ def parse(path, response)
44
+ status = response.code.to_i
45
+ body = begin
46
+ JSON.parse(response.body.to_s)
47
+ rescue JSON::ParserError
48
+ nil
49
+ end
50
+
51
+ if status.between?(200, 299)
52
+ return body if body.is_a?(Hash)
53
+ raise ConnectionError, "unparseable body from #{path} (HTTP #{status})"
54
+ end
55
+
56
+ # A non-2xx with a status_code we classify (e.g. 401/40100) still goes to the
57
+ # client, so it raises the same class as the same code would on a 2xx.
58
+ return body if body.is_a?(Hash) && API_ERRORS.key?(body["status_code"])
59
+
60
+ status_code, status_message = body.values_at("status_code", "status_message") if body.is_a?(Hash)
61
+ raise DataForSeo.http_error(http_status: status, path:, status_code:, status_message:)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataForSeo
4
+ # requested is what was sent in THIS request, so a caller persisting one
5
+ # slice can tell "omitted from the answer" from "not asked yet".
6
+ class Response
7
+ attr_reader :path, :cost, :tasks, :items, :requested
8
+
9
+ def initialize(path:, cost:, tasks:, items:, requested:)
10
+ @path = path
11
+ @cost = cost.to_f
12
+ @tasks = tasks
13
+ @items = items
14
+ @requested = requested
15
+ end
16
+
17
+ def self.combine(responses, path:, requested:)
18
+ new(path:, requested:,
19
+ cost: responses.sum(0.0, &:cost),
20
+ tasks: responses.flat_map(&:tasks),
21
+ items: responses.flat_map(&:items))
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataForSeo
4
+ # require "dataforseo/testing" in a test suite; never loaded by the gem itself.
5
+ module Testing
6
+ class FakeTransport
7
+ attr_reader :calls
8
+
9
+ def initialize(*bodies)
10
+ @bodies = bodies
11
+ @calls = []
12
+ end
13
+
14
+ def call(path, payload)
15
+ @calls << [path, payload]
16
+ raise "FakeTransport called more times than it has bodies" if @bodies.empty?
17
+
18
+ body = @bodies.shift
19
+ raise body if body.is_a?(Exception)
20
+
21
+ body
22
+ end
23
+ end
24
+
25
+ def self.ok_body(result:, cost: 0.0)
26
+ { "status_code" => 20000, "status_message" => "Ok.", "cost" => cost,
27
+ "tasks" => [{ "id" => "fake-task", "status_code" => 20000, "status_message" => "Ok.",
28
+ "cost" => cost, "result" => result }] }
29
+ end
30
+
31
+ def self.error_body(status_code:, status_message: "Error", on: :envelope)
32
+ if on == :task
33
+ { "status_code" => 20000, "status_message" => "Ok.", "cost" => 0.0,
34
+ "tasks" => [{ "id" => "fake-task", "status_code" => status_code, "status_message" => status_message }] }
35
+ else
36
+ { "status_code" => status_code, "status_message" => status_message, "tasks" => [] }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataForSeo
4
+ VERSION = "0.3.1"
5
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dataforseo"
data/lib/dataforseo.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dataforseo/version"
4
+ require_relative "dataforseo/errors"
5
+ require_relative "dataforseo/response"
6
+ require_relative "dataforseo/http_transport"
7
+ require_relative "dataforseo/client"
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dataforseo-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.1
5
+ platform: ruby
6
+ authors:
7
+ - Anton Kopylov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'A dependency-free Ruby client for DataForSEO Live endpoints: envelope
13
+ checking, typed errors, batching and a fake transport for tests.'
14
+ email:
15
+ - anton@tonic20.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - lib/dataforseo-ruby.rb
24
+ - lib/dataforseo.rb
25
+ - lib/dataforseo/client.rb
26
+ - lib/dataforseo/errors.rb
27
+ - lib/dataforseo/http_transport.rb
28
+ - lib/dataforseo/response.rb
29
+ - lib/dataforseo/testing.rb
30
+ - lib/dataforseo/version.rb
31
+ homepage: https://github.com/tonic20/dataforseo-ruby
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ homepage_uri: https://github.com/tonic20/dataforseo-ruby
36
+ source_code_uri: https://github.com/tonic20/dataforseo-ruby
37
+ changelog_uri: https://github.com/tonic20/dataforseo-ruby/blob/main/CHANGELOG.md
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '3.4'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.6.9
53
+ specification_version: 4
54
+ summary: Unofficial Ruby client for the DataForSEO API
55
+ test_files: []