WebScraping.AI Ruby Client
Official Ruby client for the WebScraping.AI API — web scraping with Chromium JavaScript rendering, rotating datacenter/residential/stealth proxies, and AI-powered question answering and structured field extraction on any page. See the API documentation for the full parameter reference.
Installation
# Gemfile
gem "webscraping_ai", "~> 4.1"Or:
gem install webscraping_aiRequires Ruby 3.1+.
Quick start
Sign up to get an API key — a free trial, no credit card required. Your key lives in the dashboard.
require "webscraping_ai"
client = WebScrapingAI::Client.new(api_key: ENV.fetch("WEBSCRAPING_AI_API_KEY"))
# Page HTML
html = client.html("https://example.com", js: true)
# Visible text
text = client.text("https://example.com")
# CSS-selected fragment
title = client.selected("https://example.com", selector: "h1")
# Multiple selectors at once
fragments = client.selected_multiple("https://example.com", selectors: ["h1", ".price"])
# Ask the LLM a question about the page
answer = client.question("https://example.com", question: "What is the main product?")
# Extract structured fields with the LLM
data = client.fields(
"https://example.com",
fields: {
title: "Main product title",
price: "Current product price",
description: "Full product description"
}
)
# Google search results (SERP) for a query
results = client.serp(q: "coffee machines", gl: "us", hl: "en", page: 1)
results["organic_results"].first["link"]
# Two pages of results searched from Austin, as one de-duplicated list
results = client.serp(q: "coffee machines", location: "Austin, TX", pages: 2)
# Location names for serp's location: (free)
client.serp_locations("austin").first["canonical_name"] # => "Austin,Texas,United States"
# Structured data for any public web page
page = client.data("https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html")
page["data"]["title"]
# Check your account quota
info = client.account
# => { "remaining_api_calls" => 200_000, "resets_at" => 1_617_073_667, "remaining_concurrency" => 100 }Configuration
Configure globally once, then create clients without arguments:
WebScrapingAI.configure do |config|
config.api_key = ENV.fetch("WEBSCRAPING_AI_API_KEY")
config.timeout = 60 # seconds, total request timeout
config.open_timeout = 10 # seconds, connection timeout
end
client = WebScrapingAI::Client.newThe gem also reads WEBSCRAPING_AI_API_KEY from the environment automatically.
Per-instance overrides:
client = WebScrapingAI::Client.new(
api_key: "...",
timeout: 90,
base_url: "https://api.webscraping.ai"
)Endpoints and options
All page-fetching endpoints accept these common options (passed as keyword arguments):
| Option | Type | Default | Description |
|---|---|---|---|
headers |
Hash |
— | HTTP headers to send to the target page (e.g. { "Cookie" => "session=..." }) |
timeout |
Integer |
10000 |
Page retrieval timeout in ms (1–30000) |
js |
Boolean |
true |
Execute on-page JavaScript via headless Chromium |
js_timeout |
Integer |
2000 |
JS rendering timeout in ms (1–20000) |
wait_for |
String |
— | CSS selector to wait for before returning (overrides js_timeout) |
proxy |
String |
"datacenter" |
One of datacenter, residential, stealth
|
country |
String |
"us" |
Proxy country: us, gb, de, it, fr, ca, es, ru, jp, kr, in, hk, tr
|
custom_proxy |
String |
— | Your own proxy in http://user:pass@host:port form |
device |
String |
"desktop" |
One of desktop, mobile, tablet
|
error_on_404 |
Boolean |
false |
Raise an error if the target page returns 404 |
error_on_redirect |
Boolean |
false |
Raise an error if the target page redirects |
js_script |
String |
— | Custom JS to execute on the page |
Endpoint-specific options:
-
#html—return_script_result(Boolean),format("json"/"text") -
#text—text_format("plain"/"xml"/"json"),return_links(Boolean, only withtext_format: "json") -
#selected—selector(String),format("json"/"text") -
#selected_multiple—selectors(Array<String>or singleString) -
#question—question(String, required),format("json"/"text") -
#fields—fields(Hash<String, String>, required) — keys are field names, values are descriptions
Returns: String for HTML/text responses, Hash/Array for JSON responses.
SERP (#serp)
#serp is query-shaped rather than URL-shaped, so none of the page-fetch options above apply.
It returns the parsed search results as a Hash. Billed per results page returned (see pricing); failed searches are not charged.
Every search runs from one fixed location: location, lat + lon, or uule, otherwise the capital of
the gl country (for the few countries outside the location list, Google places the search by the exit IP),
so repeats and later pages are consistent. gl defaults to the location's country.
pages (1–5) fetches consecutive pages, starting at page, as one de-duplicated list, billed per page returned.
| Option | Type | Default | Description |
|---|---|---|---|
q |
String |
— | Search query (required; blank or non-String raises ArgumentError) |
engine |
String |
"google" |
Search engine; currently only google
|
gl |
String |
"us" |
Two-letter country code for the search. Defaults to the country of location, lat/lon or uule, otherwise us
|
hl |
String |
"en" |
Two-letter language code for the results |
page |
Integer |
1 |
Results page number (10 results per page). Must be an Integer >= 1, otherwise ArgumentError; the server rejects values above 100 with a 400 (not billed) |
pages |
Integer |
1 |
Number of consecutive results pages to fetch in one call, starting at page, from 1 to 5. Duplicates across pages are removed, position counts through the combined list and each result gets a page key. Must be an Integer >= 1, otherwise ArgumentError
|
location |
String |
— | Where the search runs from: a name such as "Austin,Texas,United States" or "Austin, TX", or an id from #serp_locations. Large cities also match local names, e.g. "Москва". Sent as-is; an unknown name returns a 400 (not billed). Cannot be combined with uule, lat or lon
|
uule |
String |
— | Google-encoded location (w+CAIQICI… or a+…). Cannot be combined with location, lat or lon
|
lat |
Float |
— | Latitude of the search origin in decimal degrees (-90 to 90). Requires lon. Must be a finite number, otherwise ArgumentError; sent as a plain decimal rounded to 7 places |
lon |
Float |
— | Longitude of the search origin in decimal degrees (-180 to 180). Requires lat. Same rules as lat
|
radius |
Integer |
— | Radius in meters around location or lat/lon, from 1 to 1000. Must be an Integer >= 1, otherwise ArgumentError
|
Ranges and which location options can be combined are checked by the server (a 400, not billed).
results = client.serp(q: "coffee machines", gl: "gb", page: 2)
results["search_information"]["organic_results_state"] # => "Results for exact spelling"
results["organic_results"].each do |r|
puts "#{r["position"]}. #{r["title"]} — #{r["link"]}"
end
results["pagination"] # => { "current" => 2, "next" => 3 }
results = client.serp(q: "coffee machines", location: "Austin, TX", pages: 2)
results["search_parameters"]["location_used"] # => "Austin,Texas,United States"
results["organic_results"].last["page"] # => 2 (position counts through both pages)
results["pagination"]["pages_returned"] # => 2Response keys: search_parameters (engine, q, gl, hl, page; location_used, lat and lon, usually
present, which are the gl country's capital when no location is given; location_requested and radius when given;
pages only above 1), search_information
(query_displayed, organic_results_state, optional showing_results_for and total_results),
organic_results (position — 1-based within the page, or through the combined list with pages above 1 —
title, link, domain, displayed_link, optional snippet and date, and page with pages above 1),
optional related_searches (query), and pagination (current, optional next, and pages_returned with
pages above 1). Optional keys are absent when Google does not show them.
SERP locations (#serp_locations)
#serp_locations(q = nil, limit: nil) looks up names for #serp's location: option. It is free and the
endpoint needs no API key (the client still sends yours). It returns an Array of up to 10 location Hashes,
most populous first. q is part of an English location name (without it, the most populous locations are
returned); limit must be an Integer >= 1 (the server allows up to 10).
locations = client.serp_locations("austin", limit: 3)
locations.first
# => { "id" => "1026201", "google_id" => 1026201, "google_parent_id" => 21176, "name" => "Austin",
# "canonical_name" => "Austin,Texas,United States", "country_code" => "US", "target_type" => "City",
# "reach" => 974447, "gps" => [-97.7431, 30.2672] }
client.serp(q: "coffee machines", location: locations.first["canonical_name"]) # or its "id"gps is [longitude, latitude], target_type is an open set of strings (e.g. City, State, Country),
and google_parent_id is nil for countries.
Structured data (#data)
#data(url, country: nil, transcript: nil, transcript_language: nil, **params) returns structured JSON
for any public web page as a Hash. Pass the page's normal URL; the site the page belongs to (provider,
e.g. toscrape.com) and the kind of page (type, e.g. product) are detected from it. See
pricing.
Some popular sites have dedicated parsers with fixed fields. For other sites, the first request for a new
kind of page also has an AI model learn the page's structure, so it can take up to about 45 seconds (keep
the client timeout, 60 seconds by default, above that); afterwards pages of that kind on that site
return data straight away for every user. The client never checks the
URL's site, so new sites and parsers work without a gem upgrade.
| Option | Type | Default | Description |
|---|---|---|---|
url |
String |
— | Page URL (required, positional; blank or non-String raises ArgumentError) |
country |
String |
"us" |
Two-letter country code of the proxy used to fetch the page, us by default |
transcript |
Boolean |
false |
Video pages on sites with a dedicated parser only. Also fetch the video's transcript into data.transcript. It's null when no matching captions are available. If the transcript fetch itself fails, the whole request fails with a 500 and is not charged |
transcript_language |
String |
— | With transcript: true. Caption language to pick, e.g. en or de. Without it, English is preferred, then the first available track. If the video has no captions in that language, data.transcript is null |
**params |
String, Integer, Float, boolean |
— | Extra query params sent as-is, for provider-specific params added later (nil omits one). api_key, url, country, transcript and transcript_language raise ArgumentError; use the named options for the last three |
result = client.data("https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html")
result["request_parameters"] # => { "url" => "...", "provider" => "toscrape.com", "type" => "product" }
result["parse_status"] # => "ok" (or "parse_failed" / "not_found")
result["data"] # => { "title" => "A Light in the Attic", "price" => 51.77, "currency" => "GBP" }
# shape depends on provider and type; may be nil503 — structure still being learned. When learning the page's structure takes longer than the
request, or another request is already learning that site, the API returns a 503 with error_code "structure_processing" and retry_after (seconds),
plus a Retry-After header; not charged. It is raised as a plain WebScrapingAI::ApiError with status
503; the body is in JSON.parse(e.response_body). Retry after retry_after seconds; the retry usually
returns data (if learning failed meanwhile, it may learn again or return the 422 below):
url = "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"
begin
result = client.data(url)
rescue WebScrapingAI::ApiError => e
raise unless e.status == 503
# { "message" => "...", "error_code" => "structure_processing", "retry_after" => 30 }
sleep JSON.parse(e.response_body)["retry_after"] || 30
result = client.data(url)
end422 — no structured data. If no structured data can be found on the page (empty page, login wall),
the API returns a 422 with error_code "structure_unavailable", not charged. It is raised as a plain
WebScrapingAI::ApiError with status 422; the error_code is in JSON.parse(e.response_body). Use
#fields for such pages instead.
Other /data errors. Redirects within the same site (http→https, www, trailing slash) are followed;
a redirect to another site raises WebScrapingAI::ServerError (500, error_code "target_redirect", the
target in the message), not charged. Starting too many new kinds of pages in an hour raises
WebScrapingAI::RateLimitError (429, error_code "rate_limited").
provider, type and parse_status are open sets of strings, and data is the decoded JSON as-is
(no per-site classes), so new sites and fields show up without a gem release.
Error handling
All API errors inherit from WebScrapingAI::ApiError and expose #status, #message, #status_code, #status_message, #body, and #response_body.
begin
client.html("https://example.com")
rescue WebScrapingAI::RateLimitError => e
# 429 — too many concurrent requests
sleep 1 and retry
rescue WebScrapingAI::PaymentRequiredError => e
# 402 — out of API credits
rescue WebScrapingAI::AuthenticationError => e
# 403 — wrong API key
rescue WebScrapingAI::BadRequestError => e
# 400 — invalid parameters
rescue WebScrapingAI::ServerError => e
# 500 — target page returned a non-2xx code, or unexpected error.
# e.status_code / e.status_message expose the target page's response.
rescue WebScrapingAI::GatewayTimeoutError => e
# 504 — page took longer than `timeout` ms to load. Try a higher `timeout:`.
rescue WebScrapingAI::TimeoutError => e
# Client-side: the HTTP request exceeded `Client#timeout`.
rescue WebScrapingAI::ConnectionError => e
# Network failure before a response was received.
rescue WebScrapingAI::ApiError => e
# Any other status, e.g. 503 or 422 from #data (structure still being learned / no structured data).
endDevelopment
bin/setup # bundle install
bundle exec rspec
bundle exec rubocopSmoke testing
bin/smoke.rb hits every endpoint once against the live API, loading the gem from lib/ so it tests the working tree. It is not part of the spec suite and uses real credits (see pricing): the four page calls run with js: false and proxy: "datacenter" to keep the cost down, there are two AI calls (question, fields), two SERP calls (one plain, one two-page search from Austin, billed per page returned) and one /data call on a page on a site with a dedicated parser. serp_locations is free. A second /data call on https://example.com/ (any site) must come back as a success envelope, a free 503 structure_processing, or a free 422 structure_unavailable, proving there is no client-side site filter. Each case checks the result shape as well as exceptions (e.g. SERP must return organic results for the right query, the Austin SERP must report an Austin location_used, serp_locations must find Austin, /data must parse a title, selected_multiple must match something), and failure messages redact the API key.
WEBSCRAPING_AI_API_KEY=... bundle exec rake smoke
# or: WEBSCRAPING_AI_API_KEY=... ruby bin/smoke.rbEach endpoint prints an ok or FAIL line; the script exits non-zero if any call fails.
Links
- WebScraping.AI — features, pricing, signup
- API documentation
- Dashboard — API key, usage, request builder
- Other official clients: Python · JavaScript · PHP · Go · Java · .NET · CLI · MCP server · n8n node
- Support: support@webscraping.ai
License
MIT — see LICENSE.