serpcheap

Gem Version Gem downloads License: MIT

Official Ruby client for the serp.cheap Google SERP API.

A thin, dependency-free client built on net/http. Works on Ruby 2.7+.

Install

gem install serpcheap

Or in a Gemfile:

gem "serpcheap"

Quickstart

require "serpcheap"

client = SerpCheap::Client.new("KEY")
res = client.search("best running shoes", gl: "us")

puts res.organic.first.title

Get an API key at app.serp.cheap.

Search parameters

client.search("best running shoes",
  gl: "us",      # country, default "us"
  hl: "en",      # UI language (optional)
  tbs: "qdr:d",  # time filter: qdr:h / qdr:d / qdr:w (optional)
  page: 1        # 1-indexed page, default 1
)

The response is a SerpCheap::SearchResponse with reader methods:

res.search           # the query (String)
res.page             # page number (Integer)
res.organic          # Array<OrganicResult> (always an array)
res.ads              # Array<Ad> or nil
res.knowledge_graph  # KnowledgeGraph or nil
res.people_also_ask  # Array<String> or nil
res.related_searches # Array<RelatedSearch> or nil
res.stats            # SearchStats(balance, cost, cached) or nil

Multiple pages

# Eagerly fetch pages 1..5; stops on the first empty page.
pages = client.search_pages("best running shoes", from: 1, to: 5, gl: "us")

Attach page scraping to a search — each organic result gains content (markdown) and, when requested, a screenshot_url (48h presigned URL):

res = client.search("best running shoes", scrape: {
  render_js: true,   # headless render for JS-heavy pages
  screenshot: true,  # capture a full-page screenshot
  top_n: 3           # how many top results to scrape (default 5)
})

res.organic.first.content        # markdown, or nil
res.organic.first.screenshot_url # String, or nil
res.organic.first.scrape_error   # why a page couldn't be scraped, or nil

Scrape a single page

page = client.scrape("https://example.com",
  render_js: true,
  screenshot: true,
  wait_for: "#main",      # CSS selector to await (render_js only)
  wait_ms: 500,           # extra settle time (render_js only)
  screenshot_width: 1920, # default 1920, max 1920
  screenshot_height: 1080 # default 1080, max 1920
)

page.title          # String or nil
page.content        # markdown or nil
page.content_text   # plain text or nil
page.screenshot_url # String or nil
page.stats          # ScrapeStats(balance, cost) or nil

Rank tracking

Find where a domain or URL ranks for a keyword:

res = client.rank("example.com", "best running shoes",
  gl: "us",
  pages: 3,            # result pages to scan, 1..10 (default 1)
  match_type: "domain" # "domain" (registrable domain) or "exact" (identical URL)
)

res.found    # boolean
res.rank     # absolute rank of the best match, or nil
res.matches  # Array<RankMatch> (rank, page, position_on_page, link, title)
res.organic  # Array<OrganicResult> across scanned pages
res.stats    # RankStats(balance, cost, pages_cached, pages_fresh) or nil

Client options

SerpCheap::Client.new("KEY",
  base_url: "https://api.serp.cheap", # default
  timeout_ms: 15_000,                 # default
  max_retries: 2                      # default
)

Transient failures (429, 503, timeouts, network errors) are retried with backoff, honoring the API's retry_after_ms. 4xx errors are never retried.

Errors

Every failure raises SerpCheap::Error:

begin
  client.search("coffee")
rescue SerpCheap::Error => e
  e.error_code     # e.g. "insufficient_credits", "rate_limited"
  e.status         # HTTP status (Integer or nil)
  e.retry_after_ms # set for rate_limited (Integer or nil)
  e.retryable?     # boolean
end

Error codes mirror the API taxonomy: invalid_request, missing_api_key, unknown_api_key, inactive_api_key, account_blocked, insufficient_credits, rate_limited, request_in_progress, too_many_concurrent_requests, service_temporarily_unavailable, result_timeout, plus the client-side client_timeout, network_error, and invalid_response.

License

MIT