serpcheap 0.2.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +157 -0
- data/lib/serpcheap/client.rb +136 -0
- data/lib/serpcheap/error.rb +80 -0
- data/lib/serpcheap/models.rb +277 -0
- data/lib/serpcheap/version.rb +5 -0
- data/lib/serpcheap.rb +10 -0
- metadata +54 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 940f0f13aec21e4e43f41fa85fb18a29e06805a80880bf8589a1264791d853b2
|
|
4
|
+
data.tar.gz: 9eeb6ef64c9822434effae12d0df75530072538bf1312a3431ea7134096a2a85
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0ee196a700dde488b36e8729184944bc02f65b785d6f3e31d1fc6109530060f975586ab985057922689aacb9e0d8e964ea4f17e3950b4b9976cd5ac1c60c1eb7
|
|
7
|
+
data.tar.gz: 8ad6a1aa6508439c544a697e58938632e32c340a888360344253635c4f1661a462c5d1331674fe70a10030f58d9299335677af847c7e918a1b13b13a8f05f1c0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) serp.cheap
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# serpcheap
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/serpcheap)
|
|
4
|
+
[](https://rubygems.org/gems/serpcheap)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Official Ruby client for the [serp.cheap](https://serp.cheap) Google SERP API.
|
|
8
|
+
|
|
9
|
+
A thin, dependency-free client built on `net/http`. Works on Ruby 2.7+.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install serpcheap
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or in a Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "serpcheap"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "serpcheap"
|
|
27
|
+
|
|
28
|
+
client = SerpCheap::Client.new("KEY")
|
|
29
|
+
res = client.search("best running shoes", gl: "us")
|
|
30
|
+
|
|
31
|
+
puts res.organic.first.title
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Get an API key at [app.serp.cheap](https://app.serp.cheap).
|
|
35
|
+
|
|
36
|
+
## Search parameters
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
client.search("best running shoes",
|
|
40
|
+
gl: "us", # country, default "us"
|
|
41
|
+
hl: "en", # UI language (optional)
|
|
42
|
+
tbs: "qdr:d", # time filter: qdr:h / qdr:d / qdr:w (optional)
|
|
43
|
+
page: 1 # 1-indexed page, default 1
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The response is a `SerpCheap::SearchResponse` with reader methods:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
res.search # the query (String)
|
|
51
|
+
res.page # page number (Integer)
|
|
52
|
+
res.organic # Array<OrganicResult> (always an array)
|
|
53
|
+
res.ads # Array<Ad> or nil
|
|
54
|
+
res.knowledge_graph # KnowledgeGraph or nil
|
|
55
|
+
res.people_also_ask # Array<String> or nil
|
|
56
|
+
res.related_searches # Array<RelatedSearch> or nil
|
|
57
|
+
res.stats # SearchStats(balance, cost, cached) or nil
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Multiple pages
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Eagerly fetch pages 1..5; stops on the first empty page.
|
|
64
|
+
pages = client.search_pages("best running shoes", from: 1, to: 5, gl: "us")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Scrape page content with the search
|
|
68
|
+
|
|
69
|
+
Attach page scraping to a search — each organic result gains `content`
|
|
70
|
+
(markdown) and, when requested, a `screenshot_url` (48h presigned URL):
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
res = client.search("best running shoes", scrape: {
|
|
74
|
+
render_js: true, # headless render for JS-heavy pages
|
|
75
|
+
screenshot: true, # capture a full-page screenshot
|
|
76
|
+
top_n: 3 # how many top results to scrape (default 5)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
res.organic.first.content # markdown, or nil
|
|
80
|
+
res.organic.first.screenshot_url # String, or nil
|
|
81
|
+
res.organic.first.scrape_error # why a page couldn't be scraped, or nil
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Scrape a single page
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
page = client.scrape("https://example.com",
|
|
88
|
+
render_js: true,
|
|
89
|
+
screenshot: true,
|
|
90
|
+
wait_for: "#main", # CSS selector to await (render_js only)
|
|
91
|
+
wait_ms: 500, # extra settle time (render_js only)
|
|
92
|
+
screenshot_width: 1920, # default 1920, max 1920
|
|
93
|
+
screenshot_height: 1080 # default 1080, max 1920
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
page.title # String or nil
|
|
97
|
+
page.content # markdown or nil
|
|
98
|
+
page.content_text # plain text or nil
|
|
99
|
+
page.screenshot_url # String or nil
|
|
100
|
+
page.stats # ScrapeStats(balance, cost) or nil
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Rank tracking
|
|
104
|
+
|
|
105
|
+
Find where a domain or URL ranks for a keyword:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
res = client.rank("example.com", "best running shoes",
|
|
109
|
+
gl: "us",
|
|
110
|
+
pages: 3, # result pages to scan, 1..10 (default 1)
|
|
111
|
+
match_type: "domain" # "domain" (registrable domain) or "exact" (identical URL)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
res.found # boolean
|
|
115
|
+
res.rank # absolute rank of the best match, or nil
|
|
116
|
+
res.matches # Array<RankMatch> (rank, page, position_on_page, link, title)
|
|
117
|
+
res.organic # Array<OrganicResult> across scanned pages
|
|
118
|
+
res.stats # RankStats(balance, cost, pages_cached, pages_fresh) or nil
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Client options
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
SerpCheap::Client.new("KEY",
|
|
125
|
+
base_url: "https://api.serp.cheap", # default
|
|
126
|
+
timeout_ms: 15_000, # default
|
|
127
|
+
max_retries: 2 # default
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Transient failures (`429`, `503`, timeouts, network errors) are retried with
|
|
132
|
+
backoff, honoring the API's `retry_after_ms`. `4xx` errors are never retried.
|
|
133
|
+
|
|
134
|
+
## Errors
|
|
135
|
+
|
|
136
|
+
Every failure raises `SerpCheap::Error`:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
begin
|
|
140
|
+
client.search("coffee")
|
|
141
|
+
rescue SerpCheap::Error => e
|
|
142
|
+
e.error_code # e.g. "insufficient_credits", "rate_limited"
|
|
143
|
+
e.status # HTTP status (Integer or nil)
|
|
144
|
+
e.retry_after_ms # set for rate_limited (Integer or nil)
|
|
145
|
+
e.retryable? # boolean
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Error codes mirror the API taxonomy: `invalid_request`, `missing_api_key`,
|
|
150
|
+
`unknown_api_key`, `inactive_api_key`, `account_blocked`, `insufficient_credits`,
|
|
151
|
+
`rate_limited`, `request_in_progress`, `too_many_concurrent_requests`,
|
|
152
|
+
`service_temporarily_unavailable`, `result_timeout`, plus the client-side
|
|
153
|
+
`client_timeout`, `network_error`, and `invalid_response`.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SerpCheap
|
|
8
|
+
class Client
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.serp.cheap"
|
|
10
|
+
DEFAULT_TIMEOUT_MS = 15_000
|
|
11
|
+
DEFAULT_MAX_RETRIES = 2
|
|
12
|
+
|
|
13
|
+
def initialize(api_key, base_url: nil, timeout_ms: DEFAULT_TIMEOUT_MS, max_retries: DEFAULT_MAX_RETRIES)
|
|
14
|
+
raise Error.new("missing_api_key", "An API key is required. Get one at https://app.serp.cheap.") if api_key.nil? || api_key.empty?
|
|
15
|
+
|
|
16
|
+
@api_key = api_key
|
|
17
|
+
@base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
18
|
+
@timeout_ms = timeout_ms
|
|
19
|
+
@max_retries = max_retries
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Run a Google search. Retries transient errors (429/503/timeout) with backoff.
|
|
23
|
+
def search(q, gl: "us", hl: nil, tbs: nil, page: 1, scrape: nil)
|
|
24
|
+
payload = { "q" => q, "gl" => gl, "page" => page }
|
|
25
|
+
payload["hl"] = hl unless hl.nil? || hl.empty?
|
|
26
|
+
payload["tbs"] = tbs unless tbs.nil? || tbs.empty?
|
|
27
|
+
payload["scrape"] = compact(scrape) if scrape.is_a?(Hash) && !scrape.empty?
|
|
28
|
+
|
|
29
|
+
body = request("/v1/search", payload)
|
|
30
|
+
raise Error.new("invalid_response", "The API response did not match the expected shape.") unless body["organic"].is_a?(Array)
|
|
31
|
+
|
|
32
|
+
SearchResponse.from_hash(body)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Eagerly fetch pages [from..to] (inclusive). Stops on the first empty page.
|
|
36
|
+
def search_pages(q, from: 1, to: 10, **opts)
|
|
37
|
+
pages = []
|
|
38
|
+
(from..to).each do |page|
|
|
39
|
+
res = search(q, page: page, **opts)
|
|
40
|
+
pages << res
|
|
41
|
+
break if res.organic.empty?
|
|
42
|
+
end
|
|
43
|
+
pages
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fetch and extract a single page. Retries transient errors with backoff.
|
|
47
|
+
def scrape(url, render_js: nil, screenshot: nil, wait_for: nil, wait_ms: nil, screenshot_width: nil, screenshot_height: nil)
|
|
48
|
+
payload = compact(
|
|
49
|
+
"url" => url,
|
|
50
|
+
"render_js" => render_js,
|
|
51
|
+
"screenshot" => screenshot,
|
|
52
|
+
"wait_for" => wait_for,
|
|
53
|
+
"wait_ms" => wait_ms,
|
|
54
|
+
"screenshot_width" => screenshot_width,
|
|
55
|
+
"screenshot_height" => screenshot_height
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
body = request("/v1/scrape", payload)
|
|
59
|
+
raise Error.new("invalid_response", "The API response did not match the expected shape.") unless body["url"].is_a?(String)
|
|
60
|
+
|
|
61
|
+
ScrapeResponse.from_hash(body)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Find where a url/domain ranks for a keyword. Retries transient errors with backoff.
|
|
65
|
+
def rank(url, q, gl: "us", hl: nil, tbs: nil, pages: 1, match_type: "domain")
|
|
66
|
+
payload = { "url" => url, "q" => q, "gl" => gl, "pages" => pages, "match_type" => match_type }
|
|
67
|
+
payload["hl"] = hl unless hl.nil? || hl.empty?
|
|
68
|
+
payload["tbs"] = tbs unless tbs.nil? || tbs.empty?
|
|
69
|
+
|
|
70
|
+
body = request("/v1/rank", payload)
|
|
71
|
+
unless body["organic"].is_a?(Array) && body["matches"].is_a?(Array)
|
|
72
|
+
raise Error.new("invalid_response", "The API response did not match the expected shape.")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
RankResponse.from_hash(body)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def compact(hash)
|
|
81
|
+
hash.each_with_object({}) do |(k, v), out|
|
|
82
|
+
out[k.to_s] = v unless v.nil?
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def request(path, payload)
|
|
87
|
+
attempt = 0
|
|
88
|
+
loop do
|
|
89
|
+
begin
|
|
90
|
+
return once(path, payload)
|
|
91
|
+
rescue Error => e
|
|
92
|
+
raise e if !e.retryable? || attempt >= @max_retries
|
|
93
|
+
|
|
94
|
+
wait = e.retry_after_ms || [2000, 200 * (2**attempt)].min
|
|
95
|
+
sleep(wait / 1000.0)
|
|
96
|
+
attempt += 1
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def once(path, payload)
|
|
102
|
+
uri = URI.parse(@base_url + path)
|
|
103
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
104
|
+
http.use_ssl = uri.scheme == "https"
|
|
105
|
+
http.open_timeout = @timeout_ms / 1000.0
|
|
106
|
+
http.read_timeout = @timeout_ms / 1000.0
|
|
107
|
+
|
|
108
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
109
|
+
req["content-type"] = "application/json"
|
|
110
|
+
req["x-api-key"] = @api_key
|
|
111
|
+
req["user-agent"] = "serpcheap-ruby/#{VERSION}"
|
|
112
|
+
req.body = JSON.generate(payload)
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
resp = http.request(req)
|
|
116
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
|
|
117
|
+
raise Error.new("client_timeout", "No response within #{@timeout_ms} ms.")
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
redacted = e.message.to_s.gsub(@api_key, "[redacted]")
|
|
120
|
+
raise Error.new("network_error", "Could not reach #{@base_url}: #{redacted}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
status = resp.code.to_i
|
|
124
|
+
parsed = begin
|
|
125
|
+
JSON.parse(resp.body.to_s)
|
|
126
|
+
rescue JSON::ParserError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
raise Error.from_api(status, parsed.is_a?(Hash) ? parsed : {}) if status >= 400
|
|
131
|
+
raise Error.new("invalid_response", "The API returned a non-JSON body.", status: status) unless parsed.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
parsed
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SerpCheap
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
RETRYABLE = %w[
|
|
6
|
+
rate_limited
|
|
7
|
+
too_many_concurrent_requests
|
|
8
|
+
service_temporarily_unavailable
|
|
9
|
+
result_timeout
|
|
10
|
+
client_timeout
|
|
11
|
+
network_error
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :error_code, :status, :retry_after_ms, :details
|
|
15
|
+
|
|
16
|
+
def initialize(error_code, message, status: nil, retry_after_ms: nil, details: nil)
|
|
17
|
+
super(message)
|
|
18
|
+
@error_code = error_code
|
|
19
|
+
@status = status
|
|
20
|
+
@retry_after_ms = retry_after_ms
|
|
21
|
+
@details = details
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def retryable?
|
|
25
|
+
RETRYABLE.include?(@error_code)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Map a non-2xx response (status + parsed body Hash) to a typed error.
|
|
29
|
+
def self.from_api(status, body)
|
|
30
|
+
body = {} unless body.is_a?(Hash)
|
|
31
|
+
code = body["error"].is_a?(String) ? body["error"] : ""
|
|
32
|
+
msg = body["message"].is_a?(String) ? body["message"] : ""
|
|
33
|
+
|
|
34
|
+
case code
|
|
35
|
+
when "invalid_request"
|
|
36
|
+
return new("invalid_request", msg.empty? ? "The request parameters were rejected." : msg, status: status, details: body["details"])
|
|
37
|
+
when "missing_api_key"
|
|
38
|
+
return new("missing_api_key", msg.empty? ? "No API key was sent." : msg, status: status)
|
|
39
|
+
when "unknown_api_key"
|
|
40
|
+
return new("unknown_api_key", "The API key is not recognized.", status: status)
|
|
41
|
+
when "inactive_api_key"
|
|
42
|
+
return new("inactive_api_key", "The API key is inactive.", status: status)
|
|
43
|
+
when "account_blocked"
|
|
44
|
+
return new("account_blocked", msg.empty? ? "This account is blocked." : msg, status: status)
|
|
45
|
+
when "insufficient_credits"
|
|
46
|
+
required = numeric(body["required"])
|
|
47
|
+
balance = numeric(body["balance"])
|
|
48
|
+
detail = required && balance ? " (needs #{required}, balance #{balance})" : ""
|
|
49
|
+
return new("insufficient_credits", "Not enough credits#{detail}.", status: status)
|
|
50
|
+
when "rate_limited"
|
|
51
|
+
ra = numeric(body["retry_after_ms"])&.to_i
|
|
52
|
+
suffix = ra ? "; retry in #{ra} ms" : ""
|
|
53
|
+
return new("rate_limited", "Rate limit exceeded#{suffix}.", status: status, retry_after_ms: ra)
|
|
54
|
+
when "request_in_progress"
|
|
55
|
+
return new("request_in_progress", "An identical request is in flight.", status: status)
|
|
56
|
+
when "too_many_concurrent_requests"
|
|
57
|
+
return new("too_many_concurrent_requests", msg.empty? ? "Too many concurrent requests." : msg, status: status)
|
|
58
|
+
when "service_temporarily_unavailable"
|
|
59
|
+
return new("service_temporarily_unavailable", msg.empty? ? "Temporarily unavailable." : msg, status: status)
|
|
60
|
+
when "result_timeout"
|
|
61
|
+
return new("result_timeout", msg.empty? ? "The search timed out." : msg, status: status)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return new("unknown_api_key", msg.empty? ? "Authentication failed." : msg, status: status) if status == 401
|
|
65
|
+
return new("account_blocked", msg.empty? ? "Access denied." : msg, status: status) if status == 403
|
|
66
|
+
return new("rate_limited", "Rate limit exceeded.", status: status) if status == 429
|
|
67
|
+
return new("service_temporarily_unavailable", "HTTP #{status}.", status: status) if status >= 500
|
|
68
|
+
|
|
69
|
+
new("internal", msg.empty? ? "serp.cheap API returned HTTP #{status}." : msg, status: status)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.numeric(value)
|
|
73
|
+
return value if value.is_a?(Numeric)
|
|
74
|
+
return nil unless value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/)
|
|
75
|
+
|
|
76
|
+
value.include?(".") ? value.to_f : value.to_i
|
|
77
|
+
end
|
|
78
|
+
private_class_method :numeric
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SerpCheap
|
|
4
|
+
class Sitelink
|
|
5
|
+
attr_reader :title, :link
|
|
6
|
+
|
|
7
|
+
def initialize(title:, link:)
|
|
8
|
+
@title = title
|
|
9
|
+
@link = link
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.from_hash(d)
|
|
13
|
+
new(title: d["title"].to_s, link: d["link"].to_s)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class OrganicResult
|
|
18
|
+
attr_reader :position, :title, :link, :snippet, :date, :sitelinks,
|
|
19
|
+
:content, :screenshot_url, :scrape_error
|
|
20
|
+
|
|
21
|
+
def initialize(position:, title:, link:, snippet:, date: nil, sitelinks: nil,
|
|
22
|
+
content: nil, screenshot_url: nil, scrape_error: nil)
|
|
23
|
+
@position = position
|
|
24
|
+
@title = title
|
|
25
|
+
@link = link
|
|
26
|
+
@snippet = snippet
|
|
27
|
+
@date = date
|
|
28
|
+
@sitelinks = sitelinks
|
|
29
|
+
@content = content
|
|
30
|
+
@screenshot_url = screenshot_url
|
|
31
|
+
@scrape_error = scrape_error
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.from_hash(d)
|
|
35
|
+
sl = d["sitelinks"]
|
|
36
|
+
new(
|
|
37
|
+
position: d["position"].to_i,
|
|
38
|
+
title: d["title"].to_s,
|
|
39
|
+
link: d["link"].to_s,
|
|
40
|
+
snippet: d["snippet"].to_s,
|
|
41
|
+
date: d["date"],
|
|
42
|
+
sitelinks: sl.is_a?(Array) ? sl.map { |s| Sitelink.from_hash(s) } : nil,
|
|
43
|
+
content: d["content"],
|
|
44
|
+
screenshot_url: d["screenshot_url"],
|
|
45
|
+
scrape_error: d["scrape_error"]
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Ad
|
|
51
|
+
attr_reader :position, :title, :link, :displayed_link, :snippet, :block, :sitelinks
|
|
52
|
+
|
|
53
|
+
def initialize(position:, title:, link:, block:, displayed_link: nil, snippet: nil, sitelinks: nil)
|
|
54
|
+
@position = position
|
|
55
|
+
@title = title
|
|
56
|
+
@link = link
|
|
57
|
+
@block = block
|
|
58
|
+
@displayed_link = displayed_link
|
|
59
|
+
@snippet = snippet
|
|
60
|
+
@sitelinks = sitelinks
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.from_hash(d)
|
|
64
|
+
sl = d["sitelinks"]
|
|
65
|
+
new(
|
|
66
|
+
position: d["position"].to_i,
|
|
67
|
+
title: d["title"].to_s,
|
|
68
|
+
link: d["link"].to_s,
|
|
69
|
+
block: d["block"].to_s,
|
|
70
|
+
displayed_link: d["displayedLink"],
|
|
71
|
+
snippet: d["snippet"],
|
|
72
|
+
sitelinks: sl.is_a?(Array) ? sl.map { |s| Sitelink.from_hash(s) } : nil
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class RelatedSearch
|
|
78
|
+
attr_reader :query, :link
|
|
79
|
+
|
|
80
|
+
def initialize(query:, link:)
|
|
81
|
+
@query = query
|
|
82
|
+
@link = link
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.from_hash(d)
|
|
86
|
+
new(query: d["query"].to_s, link: d["link"].to_s)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class KnowledgeGraph
|
|
91
|
+
attr_reader :title, :image_url, :description, :description_source, :description_link, :attributes
|
|
92
|
+
|
|
93
|
+
def initialize(title:, image_url: nil, description: nil, description_source: nil, description_link: nil, attributes: nil)
|
|
94
|
+
@title = title
|
|
95
|
+
@image_url = image_url
|
|
96
|
+
@description = description
|
|
97
|
+
@description_source = description_source
|
|
98
|
+
@description_link = description_link
|
|
99
|
+
@attributes = attributes
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.from_hash(d)
|
|
103
|
+
new(
|
|
104
|
+
title: d["title"].to_s,
|
|
105
|
+
image_url: d["imageUrl"],
|
|
106
|
+
description: d["description"],
|
|
107
|
+
description_source: d["descriptionSource"],
|
|
108
|
+
description_link: d["descriptionLink"],
|
|
109
|
+
attributes: d["attributes"]
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class SearchStats
|
|
115
|
+
attr_reader :balance, :cost, :cached
|
|
116
|
+
|
|
117
|
+
def initialize(balance:, cost:, cached:)
|
|
118
|
+
@balance = balance
|
|
119
|
+
@cost = cost
|
|
120
|
+
@cached = cached
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.from_hash(d)
|
|
124
|
+
new(balance: d["balance"].to_i, cost: d["cost"].to_i, cached: d["cached"] == true)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class SearchResponse
|
|
129
|
+
attr_reader :search, :page, :knowledge_graph, :organic, :ads, :people_also_ask, :related_searches, :stats
|
|
130
|
+
|
|
131
|
+
def initialize(search:, page:, organic:, knowledge_graph: nil, ads: nil, people_also_ask: nil, related_searches: nil, stats: nil)
|
|
132
|
+
@search = search
|
|
133
|
+
@page = page
|
|
134
|
+
@organic = organic
|
|
135
|
+
@knowledge_graph = knowledge_graph
|
|
136
|
+
@ads = ads
|
|
137
|
+
@people_also_ask = people_also_ask
|
|
138
|
+
@related_searches = related_searches
|
|
139
|
+
@stats = stats
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.from_hash(d)
|
|
143
|
+
kg = d["knowledgeGraph"]
|
|
144
|
+
ads = d["ads"]
|
|
145
|
+
rs = d["relatedSearches"]
|
|
146
|
+
new(
|
|
147
|
+
search: d["search"].to_s,
|
|
148
|
+
page: (d["page"] || 1).to_i,
|
|
149
|
+
organic: Array(d["organic"]).map { |o| OrganicResult.from_hash(o) },
|
|
150
|
+
knowledge_graph: kg.is_a?(Hash) ? KnowledgeGraph.from_hash(kg) : nil,
|
|
151
|
+
ads: ads.is_a?(Array) ? ads.map { |a| Ad.from_hash(a) } : nil,
|
|
152
|
+
people_also_ask: d["peopleAlsoAsk"],
|
|
153
|
+
related_searches: rs.is_a?(Array) ? rs.map { |r| RelatedSearch.from_hash(r) } : nil,
|
|
154
|
+
stats: d["stats"].is_a?(Hash) ? SearchStats.from_hash(d["stats"]) : nil
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class ScrapeStats
|
|
160
|
+
attr_reader :balance, :cost
|
|
161
|
+
|
|
162
|
+
def initialize(balance:, cost:)
|
|
163
|
+
@balance = balance
|
|
164
|
+
@cost = cost
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def self.from_hash(d)
|
|
168
|
+
new(balance: d["balance"].to_i, cost: d["cost"].to_i)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class ScrapeResponse
|
|
173
|
+
attr_reader :url, :status, :title, :content, :content_text, :screenshot_url, :stats
|
|
174
|
+
|
|
175
|
+
def initialize(url:, status: nil, title: nil, content: nil, content_text: nil, screenshot_url: nil, stats: nil)
|
|
176
|
+
@url = url
|
|
177
|
+
@status = status
|
|
178
|
+
@title = title
|
|
179
|
+
@content = content
|
|
180
|
+
@content_text = content_text
|
|
181
|
+
@screenshot_url = screenshot_url
|
|
182
|
+
@stats = stats
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.from_hash(d)
|
|
186
|
+
new(
|
|
187
|
+
url: d["url"].to_s,
|
|
188
|
+
status: d["status"]&.to_i,
|
|
189
|
+
title: d["title"],
|
|
190
|
+
content: d["content"],
|
|
191
|
+
content_text: d["content_text"],
|
|
192
|
+
screenshot_url: d["screenshot_url"],
|
|
193
|
+
stats: d["stats"].is_a?(Hash) ? ScrapeStats.from_hash(d["stats"]) : nil
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
class RankMatch
|
|
199
|
+
attr_reader :rank, :page, :position_on_page, :link, :title
|
|
200
|
+
|
|
201
|
+
def initialize(rank:, page:, position_on_page:, link:, title:)
|
|
202
|
+
@rank = rank
|
|
203
|
+
@page = page
|
|
204
|
+
@position_on_page = position_on_page
|
|
205
|
+
@link = link
|
|
206
|
+
@title = title
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.from_hash(d)
|
|
210
|
+
new(
|
|
211
|
+
rank: d["rank"].to_i,
|
|
212
|
+
page: d["page"].to_i,
|
|
213
|
+
position_on_page: d["position_on_page"].to_i,
|
|
214
|
+
link: d["link"].to_s,
|
|
215
|
+
title: d["title"].to_s
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
class RankStats
|
|
221
|
+
attr_reader :balance, :cost, :pages_cached, :pages_fresh
|
|
222
|
+
|
|
223
|
+
def initialize(balance:, cost:, pages_cached:, pages_fresh:)
|
|
224
|
+
@balance = balance
|
|
225
|
+
@cost = cost
|
|
226
|
+
@pages_cached = pages_cached
|
|
227
|
+
@pages_fresh = pages_fresh
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def self.from_hash(d)
|
|
231
|
+
new(
|
|
232
|
+
balance: d["balance"].to_i,
|
|
233
|
+
cost: d["cost"].to_i,
|
|
234
|
+
pages_cached: d["pages_cached"].to_i,
|
|
235
|
+
pages_fresh: d["pages_fresh"].to_i
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
class RankResponse
|
|
241
|
+
attr_reader :url, :search, :gl, :match_type, :pages_scanned, :found, :rank,
|
|
242
|
+
:matches, :organic, :partial, :pages_failed, :stats
|
|
243
|
+
|
|
244
|
+
def initialize(url:, search:, gl:, match_type:, pages_scanned:, found:, rank:,
|
|
245
|
+
matches:, organic:, partial:, pages_failed:, stats: nil)
|
|
246
|
+
@url = url
|
|
247
|
+
@search = search
|
|
248
|
+
@gl = gl
|
|
249
|
+
@match_type = match_type
|
|
250
|
+
@pages_scanned = pages_scanned
|
|
251
|
+
@found = found
|
|
252
|
+
@rank = rank
|
|
253
|
+
@matches = matches
|
|
254
|
+
@organic = organic
|
|
255
|
+
@partial = partial
|
|
256
|
+
@pages_failed = pages_failed
|
|
257
|
+
@stats = stats
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.from_hash(d)
|
|
261
|
+
new(
|
|
262
|
+
url: d["url"].to_s,
|
|
263
|
+
search: d["search"].to_s,
|
|
264
|
+
gl: d["gl"].to_s,
|
|
265
|
+
match_type: (d["match_type"] || "domain").to_s,
|
|
266
|
+
pages_scanned: d["pages_scanned"].to_i,
|
|
267
|
+
found: d["found"] == true,
|
|
268
|
+
rank: d["rank"].nil? ? nil : d["rank"].to_i,
|
|
269
|
+
matches: Array(d["matches"]).map { |m| RankMatch.from_hash(m) },
|
|
270
|
+
organic: Array(d["organic"]).map { |o| OrganicResult.from_hash(o) },
|
|
271
|
+
partial: d["partial"] == true,
|
|
272
|
+
pages_failed: Array(d["pages_failed"]).map(&:to_i),
|
|
273
|
+
stats: d["stats"].is_a?(Hash) ? RankStats.from_hash(d["stats"]) : nil
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
data/lib/serpcheap.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "serpcheap/version"
|
|
4
|
+
require_relative "serpcheap/error"
|
|
5
|
+
require_relative "serpcheap/models"
|
|
6
|
+
require_relative "serpcheap/client"
|
|
7
|
+
|
|
8
|
+
# Official Ruby client for the serp.cheap Google SERP API.
|
|
9
|
+
module SerpCheap
|
|
10
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: serpcheap
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- serp.cheap
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: 'A thin, dependency-free client for the serp.cheap SERP API: search,
|
|
14
|
+
scrape, and rank, built on net/http.'
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/serpcheap.rb
|
|
23
|
+
- lib/serpcheap/client.rb
|
|
24
|
+
- lib/serpcheap/error.rb
|
|
25
|
+
- lib/serpcheap/models.rb
|
|
26
|
+
- lib/serpcheap/version.rb
|
|
27
|
+
homepage: https://serp.cheap
|
|
28
|
+
licenses:
|
|
29
|
+
- MIT
|
|
30
|
+
metadata:
|
|
31
|
+
homepage_uri: https://serp.cheap
|
|
32
|
+
source_code_uri: https://github.com/SerpCheap/serpcheap-ruby
|
|
33
|
+
documentation_uri: https://serp.cheap/docs
|
|
34
|
+
rubygems_mfa_required: 'true'
|
|
35
|
+
post_install_message:
|
|
36
|
+
rdoc_options: []
|
|
37
|
+
require_paths:
|
|
38
|
+
- lib
|
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: 2.7.0
|
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0'
|
|
49
|
+
requirements: []
|
|
50
|
+
rubygems_version: 3.5.22
|
|
51
|
+
signing_key:
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: Official Ruby client for the serp.cheap Google SERP API.
|
|
54
|
+
test_files: []
|