Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Changelog
* [Unreleased] Add Markdown search and archive output support
* [2026-02-23] 1.0.3 Enhance error object #16
* [2025-11-17] 1.0.2 Implement `inspect` functions for client #13
* [2025-07-18] 1.0.1 Add support for old Ruby versions (2.7, 3.0)
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,34 @@ client.close

- [Asynchronous searches](./demo/demo_async.rb) for submitting non-blocking jobs and retrieving completed results from the Search Archive API.
- [Persistent connections and connection pooling](./demo/demo_thread_pool.rb) for reusing HTTP connections across searches.
- JSON responses as Ruby hashes with `search`, or raw search-engine HTML with `html`.
- Search results as Ruby hashes with `search`, token-efficient Markdown with `md`, or raw search-engine HTML with `html`.
- SDK methods for the [Location API](https://serpapi.com/locations-api), [Search Archive API](https://serpapi.com/search-archive-api), and [Account API](https://serpapi.com/account-api).
- Configurable HTTP timeouts and symbolized or string JSON keys.

## Response formats

Use `search` for structured results decoded into a Ruby `Hash`:

```ruby
results = client.search(q: "coffee")
```

Use `md` for a token-efficient Markdown `String` optimized for LLMs and AI agents:

```ruby
markdown = client.md(q: "coffee")
```

Use `html` when you need the raw search-engine response:

```ruby
raw_html = client.html(q: "coffee")
```

Archived results are also available as Markdown with `client.search_archive(search_id, :md)`.

Learn more about [SerpApi Markdown output](https://serpapi.com/markdown-output).

## Configuration

Set defaults when creating a client, then override search parameters in individual calls:
Expand Down
26 changes: 25 additions & 1 deletion README.md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,34 @@ client.close

- [Asynchronous searches](./demo/demo_async.rb) for submitting non-blocking jobs and retrieving completed results from the Search Archive API.
- [Persistent connections and connection pooling](./demo/demo_thread_pool.rb) for reusing HTTP connections across searches.
- JSON responses as Ruby hashes with `search`, or raw search-engine HTML with `html`.
- Search results as Ruby hashes with `search`, token-efficient Markdown with `md`, or raw search-engine HTML with `html`.
- SDK methods for the [Location API](https://serpapi.com/locations-api), [Search Archive API](https://serpapi.com/search-archive-api), and [Account API](https://serpapi.com/account-api).
- Configurable HTTP timeouts and symbolized or string JSON keys.

## Response formats

Use `search` for structured results decoded into a Ruby `Hash`:

```ruby
results = client.search(q: "coffee")
```

Use `md` for a token-efficient Markdown `String` optimized for LLMs and AI agents:

```ruby
markdown = client.md(q: "coffee")
```

Use `html` when you need the raw search-engine response:

```ruby
raw_html = client.html(q: "coffee")
```

Archived results are also available as Markdown with `client.search_archive(search_id, :md)`.

Learn more about [SerpApi Markdown output](https://serpapi.com/markdown-output).

## Configuration

Set defaults when creating a client, then override search parameters in individual calls:
Expand Down
42 changes: 28 additions & 14 deletions lib/serpapi/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ module SerpApi
# features:
# * async non-block search
# * persistent HTTP connection
# * search API
# * search API with JSON, HTML, and Markdown output
# * location API
# * account API
# * search archive API
#
class Client
# Backend service URL
BACKEND = 'serpapi.com'.freeze
OUTPUT_DECODERS = { 'json' => :json, 'html' => :html, 'md' => :md }.freeze

# HTTP timeout requests
attr_reader :timeout,
Expand Down Expand Up @@ -108,9 +109,10 @@ def initialize(params = {})
# thus, most of the compute power is on the backsdend and not on the client side.
# @param [Hash] params includes engine, api_key, search fields and more..
# this override the default params provided to the constructor.
# @return [Hash] search results formatted as a Hash.
# @return [Hash|String] search results formatted as a Hash or raw text.
def search(params = {})
get('/search', :json, params)
output = query(params).transform_keys(&:to_sym)[:output]
get('/search', OUTPUT_DECODERS.fetch(output.to_s, :json), params)
end

# html search perform a search using SerpApi.com
Expand All @@ -123,6 +125,15 @@ def html(params = {})
get('/search', :html, params)
end

# Perform a search using SerpApi.com and return results optimized for LLMs and AI agents.
# The output contains Markdown tables, links, and YAML frontmatter.
#
# @param [Hash] params includes engine, api_key, search fields and more.
# @return [String] search results formatted as Markdown.
def md(params = {})
get('/search.md', :md, params)
end

# Get location using Location API
#
# example: spec/serpapi/location_api_spec.rb
Expand All @@ -146,10 +157,10 @@ def location(params = {})
# doc: https://serpapi.com/search-archive-api
#
# @param [String|Integer] search_id from original search `results[:search_metadata][:id]`
# @param [Symbol] format :json or :html [default: json, optional]
# @return [String|Hash] raw html or JSON / Hash
# @param [Symbol] format :json, :html, or :md [default: json, optional]
# @return [String|Hash] raw HTML, Markdown, or JSON / Hash
def search_archive(search_id, format = :json)
raise SerpApiError, 'format must be json or html' unless [:json, :html].include?(format)
raise SerpApiError, 'format must be json, html, or md' unless [:json, :html, :md].include?(format)

get("/searches/#{search_id}.#{format}", format)
end
Expand Down Expand Up @@ -211,9 +222,9 @@ def persistent?
# Perform HTTP GET request to the SerpApi.com backend endpoint.
#
# @param [String] endpoint HTTP service URI
# @param [Symbol] decoder type :json or :html
# @param [Symbol] decoder type :json, :html, or :md
# @param [Hash] params custom search inputs
# @return [String|Hash] raw HTML or decoded response as JSON / Hash
# @return [String|Hash] raw text or decoded response as JSON / Hash
def get(endpoint, decoder = :json, params = {})
response = execute_request(endpoint, params)
handle_response(response, decoder, endpoint, params)
Expand All @@ -232,10 +243,10 @@ def handle_response(response, decoder, endpoint, params)
case decoder
when :json
process_json_response(response, endpoint, params)
when :html
process_html_response(response, endpoint, params)
when :html, :md
process_text_response(response, endpoint, params, decoder)
else
raise SerpApiError, "not supported decoder: #{decoder}, available: :json, :html"
raise SerpApiError, "not supported decoder: #{decoder}, available: :json, :html, :md"
end
end

Expand All @@ -253,9 +264,12 @@ def process_json_response(response, endpoint, params)
data
end

def process_html_response(response, endpoint, params)
raise_http_error(response, nil, endpoint, params, decoder: :html) if response.status != 200
response.body
def process_text_response(response, endpoint, params, decoder)
raise_http_error(response, nil, endpoint, params, decoder: decoder) if response.status != 200

data = response.body.to_s
response.flush if persistent?
data
end

def validate_json_content!(data, response, endpoint, params)
Expand Down
4 changes: 2 additions & 2 deletions lib/serpapi/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module SerpApi
# - search_params: Hash of search parameters used (optional)
# - response_status: Integer HTTP or response status code (optional)
# - search_id: String id returned by the service for the search (optional)
# - decoder: Symbol representing the decoder/format used (optional) (e.g. :json)
# - decoder: Symbol representing the decoder/format used (optional) (e.g. :json or :md)
class SerpApiError < StandardError
attr_reader :serpapi_error, :search_params, :response_status, :search_id, :decoder

Expand All @@ -21,7 +21,7 @@ class SerpApiError < StandardError
# @param search_params [Hash, nil] optional hash of the search parameters used
# @param response_status [Integer, nil] optional HTTP or response status code
# @param search_id [String, nil] optional id returned by the service for the search
# @param decoder [Symbol, nil] optional decoder/format used (e.g. :json)
# @param decoder [Symbol, nil] optional decoder/format used (e.g. :json or :md)
def initialize(message = nil,
serpapi_error: nil,
search_params: nil,
Expand Down
25 changes: 25 additions & 0 deletions spec/serpapi/client/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,36 @@
expect(results.keys).to include('search_metadata'), 'search_metadata should be present in the results'
end

it 'selects the decoder from the output parameter' do
json = client.search(q: 'Coffee', location: 'Austin, TX', output: 'json')
html = client.search(q: 'Coffee', location: 'Austin, TX', output: 'html')
markdown = client.search(q: 'Coffee', location: 'Austin, TX', output: 'md')

expect(json).to be_a(Hash)
expect(html).to match(/coffee/i)
expect(markdown).to be_a(String)
expect(markdown).to start_with('---')
end

it 'search for coffee in Austin, TX and receive raw HTML' do
results = client.html(q: 'Coffee', location: 'Austin, TX')
expect(results).to match(/coffee/i)
end

it 'search for coffee in Austin, TX and receive Markdown' do
results = client.md(q: 'Coffee', location: 'Austin, TX')

expect(results).to be_a(String)
expect(results).to start_with('---')
expect(results).to include('## Organic Results')
end

it 'reports Markdown HTTP errors with their decoder' do
expect {
client.md
}.to raise_error(SerpApi::SerpApiError) { |error| expect(error.decoder).to eq(:md) }
end

it 'missing query' do
begin
client.search
Expand Down
4 changes: 4 additions & 0 deletions spec/serpapi/client/search_archive_api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
client = SerpApi::Client.new(api_key: client.api_key, engine: 'google')
results = client.search_archive(search_id)
expect(archive_search).to eq(results)

markdown = client.search_archive(search_id, :md)
expect(markdown).to be_a(String)
expect(markdown).to start_with('---')
else
client = SerpApi::Client.new(api_key: client.api_key, engine: 'google')
allow(client).to receive(:get) { search_response_mock }
Expand Down
Loading