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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +73 -0
- data/lib/dataforseo/client.rb +126 -0
- data/lib/dataforseo/errors.rb +50 -0
- data/lib/dataforseo/http_transport.rb +64 -0
- data/lib/dataforseo/response.rb +24 -0
- data/lib/dataforseo/testing.rb +40 -0
- data/lib/dataforseo/version.rb +5 -0
- data/lib/dataforseo-ruby.rb +3 -0
- data/lib/dataforseo.rb +7 -0
- metadata +55 -0
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
|
data/lib/dataforseo.rb
ADDED
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: []
|