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 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
+ [![Gem Version](https://img.shields.io/gem/v/serpcheap)](https://rubygems.org/gems/serpcheap)
4
+ [![Gem downloads](https://img.shields.io/gem/dt/serpcheap)](https://rubygems.org/gems/serpcheap)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SerpCheap
4
+ VERSION = "0.2.0" # x-release-please-version
5
+ 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: []