From 379f946aa88290c4a7e026af00f0e247c5d29a6e Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Mon, 17 Aug 2026 10:11:39 +0200 Subject: [PATCH 01/13] Add initial Markdown support --- CHANGELOG.md | 1 + README.md | 26 +++++++++++++- README.md.erb | 26 +++++++++++++- lib/serpapi/client.rb | 36 ++++++++++++++----- lib/serpapi/error.rb | 4 +-- spec/serpapi/client/client_spec.rb | 26 ++++++++++++++ .../serpapi/client/search_archive_api_spec.rb | 12 +++++++ 7 files changed, 119 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7639471..45e5596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 1943027..2d83de9 100644 --- a/README.md +++ b/README.md @@ -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 `markdown`, 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 `markdown` for a token-efficient Markdown `String` optimized for LLMs and AI agents: + +```ruby +markdown = client.markdown(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, :markdown)`. + +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: diff --git a/README.md.erb b/README.md.erb index 200bc5d..773062e 100644 --- a/README.md.erb +++ b/README.md.erb @@ -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 `markdown`, 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 `markdown` for a token-efficient Markdown `String` optimized for LLMs and AI agents: + +```ruby +markdown = client.markdown(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, :markdown)`. + +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: diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 0a8f081..f98bf26 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -7,7 +7,7 @@ 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 @@ -123,6 +123,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 markdown(params = {}) + get('/search.md', :markdown, params) + end + # Get location using Location API # # example: spec/serpapi/location_api_spec.rb @@ -146,12 +155,13 @@ 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 :markdown [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 markdown' unless [:json, :html, :markdown].include?(format) - get("/searches/#{search_id}.#{format}", format) + extension = format == :markdown ? :md : format + get("/searches/#{search_id}.#{extension}", format) end # Get account information using Account API @@ -211,9 +221,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 :markdown # @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) @@ -234,8 +244,10 @@ def handle_response(response, decoder, endpoint, params) process_json_response(response, endpoint, params) when :html process_html_response(response, endpoint, params) + when :markdown + process_markdown_response(response, endpoint, params) else - raise SerpApiError, "not supported decoder: #{decoder}, available: :json, :html" + raise SerpApiError, "not supported decoder: #{decoder}, available: :json, :html, :markdown" end end @@ -258,6 +270,14 @@ def process_html_response(response, endpoint, params) response.body end + def process_markdown_response(response, endpoint, params) + raise_http_error(response, nil, endpoint, params, decoder: :markdown) if response.status != 200 + + data = response.body.to_s + response.flush if persistent? + data + end + def validate_json_content!(data, response, endpoint, params) if data.is_a?(Hash) && data.key?(:error) raise_http_error(response, data, endpoint, params, explicit_error: data[:error]) diff --git a/lib/serpapi/error.rb b/lib/serpapi/error.rb index b082178..625d334 100644 --- a/lib/serpapi/error.rb +++ b/lib/serpapi/error.rb @@ -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 :markdown) class SerpApiError < StandardError attr_reader :serpapi_error, :search_params, :response_status, :search_id, :decoder @@ -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 :markdown) def initialize(message = nil, serpapi_error: nil, search_params: nil, diff --git a/spec/serpapi/client/client_spec.rb b/spec/serpapi/client/client_spec.rb index d3c2736..6e6fdfe 100644 --- a/spec/serpapi/client/client_spec.rb +++ b/spec/serpapi/client/client_spec.rb @@ -30,6 +30,32 @@ expect(results).to match(/coffee/i) end + it 'search for coffee in Austin, TX and receive Markdown' do + results = client.markdown(q: 'Coffee', location: 'Austin, TX') + + expect(results).to be_a(String) + expect(results).to start_with('---') + expect(results).to include('## Organic Results') + end + + it 'requests the Markdown endpoint' do + response = double(status: 200, body: "---\n## Organic Results\n", flush: :clean) + expect(client.socket).to receive(:get) + .with('/search.md', params: hash_including(q: 'Coffee')) + .and_return(response) + + expect(client.markdown(q: 'Coffee')).to start_with('---') + end + + it 'reports Markdown HTTP errors with their decoder' do + response = double(status: 400, body: 'Invalid search') + allow(client.socket).to receive(:get).and_return(response) + + expect { + client.markdown(q: 'Coffee') + }.to raise_error(SerpApi::SerpApiError) { |error| expect(error.decoder).to eq(:markdown) } + end + it 'missing query' do begin client.search diff --git a/spec/serpapi/client/search_archive_api_spec.rb b/spec/serpapi/client/search_archive_api_spec.rb index 0ecc422..60ebe44 100644 --- a/spec/serpapi/client/search_archive_api_spec.rb +++ b/spec/serpapi/client/search_archive_api_spec.rb @@ -32,4 +32,16 @@ expect(archive_search).to eq(results) end end + + it 'fetches an archived search as Markdown' do + client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY'], engine: 'google') + response = double(status: 200, body: "---\n## Organic Results\n", flush: :clean) + + expect(client.socket).to receive(:get) + .with('/searches/search-id.md', params: hash_including(api_key: ENV['SERPAPI_KEY'])) + .and_return(response) + + results = client.search_archive('search-id', :markdown) + expect(results).to eq("---\n## Organic Results\n") + end end From 3fb5120dc5ded1753b5bcdc23a2352a8f4ed71c6 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Mon, 17 Aug 2026 10:47:00 +0200 Subject: [PATCH 02/13] Rename markdown method to md --- README.md | 6 +++--- README.md.erb | 6 +++--- lib/serpapi/client.rb | 2 +- spec/serpapi/client/client_spec.rb | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2d83de9..a083999 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ 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. -- Search results as Ruby hashes with `search`, token-efficient Markdown with `markdown`, 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. @@ -67,10 +67,10 @@ Use `search` for structured results decoded into a Ruby `Hash`: results = client.search(q: "coffee") ``` -Use `markdown` for a token-efficient Markdown `String` optimized for LLMs and AI agents: +Use `md` for a token-efficient Markdown `String` optimized for LLMs and AI agents: ```ruby -markdown = client.markdown(q: "coffee") +markdown = client.md(q: "coffee") ``` Use `html` when you need the raw search-engine response: diff --git a/README.md.erb b/README.md.erb index 773062e..a0d1a95 100644 --- a/README.md.erb +++ b/README.md.erb @@ -55,7 +55,7 @@ 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. -- Search results as Ruby hashes with `search`, token-efficient Markdown with `markdown`, 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. @@ -67,10 +67,10 @@ Use `search` for structured results decoded into a Ruby `Hash`: results = client.search(q: "coffee") ``` -Use `markdown` for a token-efficient Markdown `String` optimized for LLMs and AI agents: +Use `md` for a token-efficient Markdown `String` optimized for LLMs and AI agents: ```ruby -markdown = client.markdown(q: "coffee") +markdown = client.md(q: "coffee") ``` Use `html` when you need the raw search-engine response: diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index f98bf26..63d9971 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -128,7 +128,7 @@ def html(params = {}) # # @param [Hash] params includes engine, api_key, search fields and more. # @return [String] search results formatted as Markdown. - def markdown(params = {}) + def md(params = {}) get('/search.md', :markdown, params) end diff --git a/spec/serpapi/client/client_spec.rb b/spec/serpapi/client/client_spec.rb index 6e6fdfe..28b2822 100644 --- a/spec/serpapi/client/client_spec.rb +++ b/spec/serpapi/client/client_spec.rb @@ -31,7 +31,7 @@ end it 'search for coffee in Austin, TX and receive Markdown' do - results = client.markdown(q: 'Coffee', location: 'Austin, TX') + results = client.md(q: 'Coffee', location: 'Austin, TX') expect(results).to be_a(String) expect(results).to start_with('---') @@ -44,7 +44,7 @@ .with('/search.md', params: hash_including(q: 'Coffee')) .and_return(response) - expect(client.markdown(q: 'Coffee')).to start_with('---') + expect(client.md(q: 'Coffee')).to start_with('---') end it 'reports Markdown HTTP errors with their decoder' do @@ -52,7 +52,7 @@ allow(client.socket).to receive(:get).and_return(response) expect { - client.markdown(q: 'Coffee') + client.md(q: 'Coffee') }.to raise_error(SerpApi::SerpApiError) { |error| expect(error.decoder).to eq(:markdown) } end From b063cf80f960e90cb37ab061e0d12bebde3aa274 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Mon, 17 Aug 2026 11:45:48 +0200 Subject: [PATCH 03/13] Update archive to :md --- README.md | 2 +- README.md.erb | 2 +- lib/serpapi/client.rb | 22 ++++++++++--------- lib/serpapi/error.rb | 4 ++-- spec/serpapi/client/client_spec.rb | 2 +- .../serpapi/client/search_archive_api_spec.rb | 16 ++++++++++++-- 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a083999..df1c784 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Use `html` when you need the raw search-engine response: raw_html = client.html(q: "coffee") ``` -Archived results are also available as Markdown with `client.search_archive(search_id, :markdown)`. +Archived results are also available as Markdown with `client.search_archive(search_id, :md)` or `client.search_archive(search_id, output: "md")`. Learn more about [SerpApi Markdown output](https://serpapi.com/markdown-output). diff --git a/README.md.erb b/README.md.erb index a0d1a95..a3c6864 100644 --- a/README.md.erb +++ b/README.md.erb @@ -79,7 +79,7 @@ Use `html` when you need the raw search-engine response: raw_html = client.html(q: "coffee") ``` -Archived results are also available as Markdown with `client.search_archive(search_id, :markdown)`. +Archived results are also available as Markdown with `client.search_archive(search_id, :md)` or `client.search_archive(search_id, output: "md")`. Learn more about [SerpApi Markdown output](https://serpapi.com/markdown-output). diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 63d9971..49fc571 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -129,7 +129,7 @@ def html(params = {}) # @param [Hash] params includes engine, api_key, search fields and more. # @return [String] search results formatted as Markdown. def md(params = {}) - get('/search.md', :markdown, params) + get('/search.md', :md, params) end # Get location using Location API @@ -155,13 +155,15 @@ 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, :html, or :markdown [default: json, optional] + # @param [Symbol] format :json, :html, or :md [default: json, optional] + # @param [String|Symbol, nil] output response format using the SerpApi output parameter [optional] # @return [String|Hash] raw HTML, Markdown, or JSON / Hash - def search_archive(search_id, format = :json) - raise SerpApiError, 'format must be json, html, or markdown' unless [:json, :html, :markdown].include?(format) + def search_archive(search_id, format = :json, output: nil) + format = output.to_s.to_sym unless output.nil? + raise SerpApiError, 'format must be json, html, or md' unless [:json, :html, :md].include?(format) - extension = format == :markdown ? :md : format - get("/searches/#{search_id}.#{extension}", format) + endpoint = output.nil? ? "/searches/#{search_id}.#{format}" : "/searches/#{search_id}" + get(endpoint, format, output.nil? ? {} : { output: output }) end # Get account information using Account API @@ -221,7 +223,7 @@ def persistent? # Perform HTTP GET request to the SerpApi.com backend endpoint. # # @param [String] endpoint HTTP service URI - # @param [Symbol] decoder type :json, :html, or :markdown + # @param [Symbol] decoder type :json, :html, or :md # @param [Hash] params custom search inputs # @return [String|Hash] raw text or decoded response as JSON / Hash def get(endpoint, decoder = :json, params = {}) @@ -244,10 +246,10 @@ def handle_response(response, decoder, endpoint, params) process_json_response(response, endpoint, params) when :html process_html_response(response, endpoint, params) - when :markdown + when :md process_markdown_response(response, endpoint, params) else - raise SerpApiError, "not supported decoder: #{decoder}, available: :json, :html, :markdown" + raise SerpApiError, "not supported decoder: #{decoder}, available: :json, :html, :md" end end @@ -271,7 +273,7 @@ def process_html_response(response, endpoint, params) end def process_markdown_response(response, endpoint, params) - raise_http_error(response, nil, endpoint, params, decoder: :markdown) if response.status != 200 + raise_http_error(response, nil, endpoint, params, decoder: :md) if response.status != 200 data = response.body.to_s response.flush if persistent? diff --git a/lib/serpapi/error.rb b/lib/serpapi/error.rb index 625d334..f2c3fea 100644 --- a/lib/serpapi/error.rb +++ b/lib/serpapi/error.rb @@ -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 or :markdown) + # - 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 @@ -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 or :markdown) + # @param decoder [Symbol, nil] optional decoder/format used (e.g. :json or :md) def initialize(message = nil, serpapi_error: nil, search_params: nil, diff --git a/spec/serpapi/client/client_spec.rb b/spec/serpapi/client/client_spec.rb index 28b2822..3e9a94d 100644 --- a/spec/serpapi/client/client_spec.rb +++ b/spec/serpapi/client/client_spec.rb @@ -53,7 +53,7 @@ expect { client.md(q: 'Coffee') - }.to raise_error(SerpApi::SerpApiError) { |error| expect(error.decoder).to eq(:markdown) } + }.to raise_error(SerpApi::SerpApiError) { |error| expect(error.decoder).to eq(:md) } end it 'missing query' do diff --git a/spec/serpapi/client/search_archive_api_spec.rb b/spec/serpapi/client/search_archive_api_spec.rb index 60ebe44..d9d817c 100644 --- a/spec/serpapi/client/search_archive_api_spec.rb +++ b/spec/serpapi/client/search_archive_api_spec.rb @@ -33,7 +33,7 @@ end end - it 'fetches an archived search as Markdown' do + it 'fetches an archived search as Markdown using the md format' do client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY'], engine: 'google') response = double(status: 200, body: "---\n## Organic Results\n", flush: :clean) @@ -41,7 +41,19 @@ .with('/searches/search-id.md', params: hash_including(api_key: ENV['SERPAPI_KEY'])) .and_return(response) - results = client.search_archive('search-id', :markdown) + results = client.search_archive('search-id', :md) + expect(results).to eq("---\n## Organic Results\n") + end + + it 'fetches an archived search as Markdown using the output parameter' do + client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY'], engine: 'google') + response = double(status: 200, body: "---\n## Organic Results\n", flush: :clean) + + expect(client.socket).to receive(:get) + .with('/searches/search-id', params: hash_including(output: 'md')) + .and_return(response) + + results = client.search_archive('search-id', output: 'md') expect(results).to eq("---\n## Organic Results\n") end end From 21b5347f3c8754839972d91d94980154440df789 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Tue, 18 Aug 2026 10:50:09 +0200 Subject: [PATCH 04/13] Remove double and keep only live tests --- spec/serpapi/client/client_spec.rb | 14 +-------- .../serpapi/client/search_archive_api_spec.rb | 31 +++++-------------- 2 files changed, 8 insertions(+), 37 deletions(-) diff --git a/spec/serpapi/client/client_spec.rb b/spec/serpapi/client/client_spec.rb index 3e9a94d..082ce4a 100644 --- a/spec/serpapi/client/client_spec.rb +++ b/spec/serpapi/client/client_spec.rb @@ -38,21 +38,9 @@ expect(results).to include('## Organic Results') end - it 'requests the Markdown endpoint' do - response = double(status: 200, body: "---\n## Organic Results\n", flush: :clean) - expect(client.socket).to receive(:get) - .with('/search.md', params: hash_including(q: 'Coffee')) - .and_return(response) - - expect(client.md(q: 'Coffee')).to start_with('---') - end - it 'reports Markdown HTTP errors with their decoder' do - response = double(status: 400, body: 'Invalid search') - allow(client.socket).to receive(:get).and_return(response) - expect { - client.md(q: 'Coffee') + client.md }.to raise_error(SerpApi::SerpApiError) { |error| expect(error.decoder).to eq(:md) } end diff --git a/spec/serpapi/client/search_archive_api_spec.rb b/spec/serpapi/client/search_archive_api_spec.rb index d9d817c..acfc5db 100644 --- a/spec/serpapi/client/search_archive_api_spec.rb +++ b/spec/serpapi/client/search_archive_api_spec.rb @@ -26,34 +26,17 @@ 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('---') + + markdown_from_output = client.search_archive(search_id, output: 'md') + expect(markdown_from_output).to eq(markdown) else client = SerpApi::Client.new(api_key: client.api_key, engine: 'google') allow(client).to receive(:get) { search_response_mock } expect(archive_search).to eq(results) end end - - it 'fetches an archived search as Markdown using the md format' do - client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY'], engine: 'google') - response = double(status: 200, body: "---\n## Organic Results\n", flush: :clean) - - expect(client.socket).to receive(:get) - .with('/searches/search-id.md', params: hash_including(api_key: ENV['SERPAPI_KEY'])) - .and_return(response) - - results = client.search_archive('search-id', :md) - expect(results).to eq("---\n## Organic Results\n") - end - - it 'fetches an archived search as Markdown using the output parameter' do - client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY'], engine: 'google') - response = double(status: 200, body: "---\n## Organic Results\n", flush: :clean) - - expect(client.socket).to receive(:get) - .with('/searches/search-id', params: hash_including(output: 'md')) - .and_return(response) - - results = client.search_archive('search-id', output: 'md') - expect(results).to eq("---\n## Organic Results\n") - end end From d006bcb2ee229f2df76e605473092305f8237108 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Tue, 18 Aug 2026 12:32:17 +0200 Subject: [PATCH 05/13] Pass .md directly in search_archive --- README.md | 2 +- README.md.erb | 2 +- lib/serpapi/client.rb | 7 ++----- spec/serpapi/client/search_archive_api_spec.rb | 3 --- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index df1c784..fc221af 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Use `html` when you need the raw search-engine response: raw_html = client.html(q: "coffee") ``` -Archived results are also available as Markdown with `client.search_archive(search_id, :md)` or `client.search_archive(search_id, output: "md")`. +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). diff --git a/README.md.erb b/README.md.erb index a3c6864..29a9f83 100644 --- a/README.md.erb +++ b/README.md.erb @@ -79,7 +79,7 @@ Use `html` when you need the raw search-engine response: raw_html = client.html(q: "coffee") ``` -Archived results are also available as Markdown with `client.search_archive(search_id, :md)` or `client.search_archive(search_id, output: "md")`. +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). diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 49fc571..4725ca9 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -156,14 +156,11 @@ def location(params = {}) # # @param [String|Integer] search_id from original search `results[:search_metadata][:id]` # @param [Symbol] format :json, :html, or :md [default: json, optional] - # @param [String|Symbol, nil] output response format using the SerpApi output parameter [optional] # @return [String|Hash] raw HTML, Markdown, or JSON / Hash - def search_archive(search_id, format = :json, output: nil) - format = output.to_s.to_sym unless output.nil? + def search_archive(search_id, format = :json) raise SerpApiError, 'format must be json, html, or md' unless [:json, :html, :md].include?(format) - endpoint = output.nil? ? "/searches/#{search_id}.#{format}" : "/searches/#{search_id}" - get(endpoint, format, output.nil? ? {} : { output: output }) + get("/searches/#{search_id}.#{format}", format) end # Get account information using Account API diff --git a/spec/serpapi/client/search_archive_api_spec.rb b/spec/serpapi/client/search_archive_api_spec.rb index acfc5db..2586653 100644 --- a/spec/serpapi/client/search_archive_api_spec.rb +++ b/spec/serpapi/client/search_archive_api_spec.rb @@ -30,9 +30,6 @@ markdown = client.search_archive(search_id, :md) expect(markdown).to be_a(String) expect(markdown).to start_with('---') - - markdown_from_output = client.search_archive(search_id, output: 'md') - expect(markdown_from_output).to eq(markdown) else client = SerpApi::Client.new(api_key: client.api_key, engine: 'google') allow(client).to receive(:get) { search_response_mock } From 086f6f8f0e5f51a71d4d44d8dfcead5f26a3ca9f Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 19 Aug 2026 13:39:20 +0200 Subject: [PATCH 06/13] Choose the right decoder based on output parameter --- lib/serpapi/client.rb | 6 ++++-- spec/serpapi/client/client_spec.rb | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 4725ca9..ccd23ad 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -15,6 +15,7 @@ module SerpApi class Client # Backend service URL BACKEND = 'serpapi.com'.freeze + OUTPUT_DECODERS = { 'html' => :html, 'md' => :md }.freeze # HTTP timeout requests attr_reader :timeout, @@ -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 diff --git a/spec/serpapi/client/client_spec.rb b/spec/serpapi/client/client_spec.rb index 082ce4a..003883e 100644 --- a/spec/serpapi/client/client_spec.rb +++ b/spec/serpapi/client/client_spec.rb @@ -25,6 +25,17 @@ 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) From 3d41fe7c79f41cfa18b30ba687b92b4b370df488 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 19 Aug 2026 13:40:59 +0200 Subject: [PATCH 07/13] Include json explicitely --- lib/serpapi/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index ccd23ad..9e4628a 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -15,7 +15,7 @@ module SerpApi class Client # Backend service URL BACKEND = 'serpapi.com'.freeze - OUTPUT_DECODERS = { 'html' => :html, 'md' => :md }.freeze + OUTPUT_DECODERS = { 'json' => :json, 'html' => :html, 'md' => :md }.freeze # HTTP timeout requests attr_reader :timeout, From fb033a19fd8b80f3aadf148b2117946b7b66a6c8 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 19 Aug 2026 13:44:56 +0200 Subject: [PATCH 08/13] Join response handling into process_text_response --- lib/serpapi/client.rb | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 9e4628a..75cc3be 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -243,10 +243,8 @@ 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 :md - process_markdown_response(response, endpoint, params) + when :html, :md + process_text_response(response, endpoint, params, decoder) else raise SerpApiError, "not supported decoder: #{decoder}, available: :json, :html, :md" end @@ -266,13 +264,8 @@ 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 - end - - def process_markdown_response(response, endpoint, params) - raise_http_error(response, nil, endpoint, params, decoder: :md) if response.status != 200 + 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? From 385cab66b38f9c8b5acfd6da2c4e24d9f13450b8 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Thu, 20 Aug 2026 09:29:52 +0200 Subject: [PATCH 09/13] Introduce response type decoders to always return expected type --- lib/serpapi/client.rb | 25 ++++++++++++++----------- spec/serpapi/client/client_spec.rb | 25 ++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 75cc3be..14bdbf0 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -15,7 +15,7 @@ module SerpApi class Client # Backend service URL BACKEND = 'serpapi.com'.freeze - OUTPUT_DECODERS = { 'json' => :json, 'html' => :html, 'md' => :md }.freeze + CONTENT_TYPE_DECODERS = { 'application/json' => :json, 'text/html' => :html, 'text/markdown' => :md }.freeze # HTTP timeout requests attr_reader :timeout, @@ -111,8 +111,7 @@ def initialize(params = {}) # this override the default params provided to the constructor. # @return [Hash|String] search results formatted as a Hash or raw text. def search(params = {}) - output = query(params).transform_keys(&:to_sym)[:output] - get('/search', OUTPUT_DECODERS.fetch(output.to_s, :json), params) + get('/search', :json, params) end # html search perform a search using SerpApi.com @@ -120,16 +119,16 @@ def search(params = {}) # it is useful for training AI models, RAG, debugging # or when you need to parse the HTML yourself. # - # @return [String] raw html search results directly from the search engine. + # @return [String|Hash] raw text or decoded JSON search results. def html(params = {}) - get('/search', :html, params) + get('/search.html', :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. + # @return [String|Hash] Markdown, raw HTML, or decoded JSON search results. def md(params = {}) get('/search.md', :md, params) end @@ -227,7 +226,9 @@ def persistent? # @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) + handle_response(response, response_decoder(response, decoder), endpoint, params) + ensure + response&.flush if persistent? end def execute_request(endpoint, params) @@ -239,6 +240,11 @@ def execute_request(endpoint, params) end end + def response_decoder(response, default) + content_type = response.headers['Content-Type'].to_s.split(';').first + CONTENT_TYPE_DECODERS.fetch(content_type, default) + end + def handle_response(response, decoder, endpoint, params) case decoder when :json @@ -260,16 +266,13 @@ def process_json_response(response, endpoint, params) raise_parser_error(response, endpoint, params) end - response.flush if persistent? data end 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 + response.body.to_s end def validate_json_content!(data, response, endpoint, params) diff --git a/spec/serpapi/client/client_spec.rb b/spec/serpapi/client/client_spec.rb index 003883e..9d3461c 100644 --- a/spec/serpapi/client/client_spec.rb +++ b/spec/serpapi/client/client_spec.rb @@ -36,9 +36,23 @@ expect(markdown).to start_with('---') end + it 'honors explicit output when it differs from the method' do + html_json = client.html(q: 'Coffee', location: 'Austin, TX', output: 'json') + markdown_json = client.md(q: 'Coffee', location: 'Austin, TX', output: 'json') + html_markdown = client.html(q: 'Coffee', location: 'Austin, TX', output: 'md') + markdown_html = client.md(q: 'Coffee', location: 'Austin, TX', output: 'html') + + expect(html_json).to be_a(Hash) + expect(markdown_json).to be_a(Hash) + expect(html_markdown).to start_with('---') + expect(markdown_html).to match(/\A/i) + 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) + + expect(results).to be_a(String) + expect(results).to match(/\A/i) end it 'search for coffee in Austin, TX and receive Markdown' do @@ -49,10 +63,15 @@ expect(results).to include('## Organic Results') end - it 'reports Markdown HTTP errors with their decoder' do + it 'decodes JSON errors and reuses the persistent connection' do expect { client.md - }.to raise_error(SerpApi::SerpApiError) { |error| expect(error.decoder).to eq(:md) } + }.to raise_error(SerpApi::SerpApiError) do |error| + expect(error.decoder).to eq(:json) + expect(error.serpapi_error).to include('Missing query') + end + + expect(client.md(q: 'Coffee')).to start_with('---') end it 'missing query' do From 39b7e3bd8fe9dbe93595893ec80ea082096290ae Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Thu, 20 Aug 2026 10:27:08 +0200 Subject: [PATCH 10/13] Keep .md and .html methods as pure shortcuts --- lib/serpapi/client.rb | 24 ++++++++++++++---------- spec/serpapi/client/client_spec.rb | 16 ++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 14bdbf0..64a229d 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -15,6 +15,8 @@ module SerpApi class Client # Backend service URL BACKEND = 'serpapi.com'.freeze + # SerpApi errors are JSON even when HTML or Markdown output was requested. + # Decode the actual Content-Type so structured errors and successful JSON responses become Hashes. CONTENT_TYPE_DECODERS = { 'application/json' => :json, 'text/html' => :html, 'text/markdown' => :md }.freeze # HTTP timeout requests @@ -119,18 +121,18 @@ def search(params = {}) # it is useful for training AI models, RAG, debugging # or when you need to parse the HTML yourself. # - # @return [String|Hash] raw text or decoded JSON search results. + # @return [String] raw HTML search results. def html(params = {}) - get('/search.html', :html, params) + get('/search', :html, params, output: 'html') 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|Hash] Markdown, raw HTML, or decoded JSON search results. + # @return [String] search results formatted as Markdown. def md(params = {}) - get('/search.md', :md, params) + get('/search', :md, params, output: 'md') end # Get location using Location API @@ -200,11 +202,13 @@ def inspect # @param [Hash] params to merge with default parameters provided to the constructor. # @return [Hash] merged query parameters after cleanup - def query(params) + def query(params, output = nil) raise SerpApiError, "params must be hash, not: #{params.class}" unless params.instance_of?(Hash) # merge default params with custom params q = @params.clone.merge(params) + q.reject! { |key, _| key.to_s == 'output' } if output + q[:output] = output if output # do not pollute default params with custom params q.delete(:symbolize_names) if q.key?(:symbolize_names) @@ -224,19 +228,19 @@ def persistent? # @param [Symbol] decoder type :json, :html, or :md # @param [Hash] params custom search inputs # @return [String|Hash] raw text or decoded response as JSON / Hash - def get(endpoint, decoder = :json, params = {}) - response = execute_request(endpoint, params) + def get(endpoint, decoder = :json, params = {}, output: nil) + response = execute_request(endpoint, params, output) handle_response(response, response_decoder(response, decoder), endpoint, params) ensure response&.flush if persistent? end - def execute_request(endpoint, params) + def execute_request(endpoint, params, output = nil) if persistent? - @socket.get(endpoint, params: query(params)) + @socket.get(endpoint, params: query(params, output)) else url = "https://#{BACKEND}#{endpoint}" - HTTP.timeout(timeout).get(url, params: query(params)) + HTTP.timeout(timeout).get(url, params: query(params, output)) end end diff --git a/spec/serpapi/client/client_spec.rb b/spec/serpapi/client/client_spec.rb index 9d3461c..17aa119 100644 --- a/spec/serpapi/client/client_spec.rb +++ b/spec/serpapi/client/client_spec.rb @@ -36,16 +36,12 @@ expect(markdown).to start_with('---') end - it 'honors explicit output when it differs from the method' do - html_json = client.html(q: 'Coffee', location: 'Austin, TX', output: 'json') - markdown_json = client.md(q: 'Coffee', location: 'Austin, TX', output: 'json') - html_markdown = client.html(q: 'Coffee', location: 'Austin, TX', output: 'md') - markdown_html = client.md(q: 'Coffee', location: 'Austin, TX', output: 'html') - - expect(html_json).to be_a(Hash) - expect(markdown_json).to be_a(Hash) - expect(html_markdown).to start_with('---') - expect(markdown_html).to match(/\A/i) + it 'ignores output parameters passed to format shortcut methods' do + html = client.html(q: 'Coffee', location: 'Austin, TX', output: 'json') + markdown = client.md(q: 'Coffee', location: 'Austin, TX', 'output' => 'html') + + expect(html).to match(/\A/i) + expect(markdown).to start_with('---') end it 'search for coffee in Austin, TX and receive raw HTML' do From ce01fc3ad82e427802f125e9da010377eb1eec10 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Thu, 20 Aug 2026 12:36:48 +0200 Subject: [PATCH 11/13] Do not change query signature --- lib/serpapi/client.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 64a229d..dcc58d7 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -202,13 +202,11 @@ def inspect # @param [Hash] params to merge with default parameters provided to the constructor. # @return [Hash] merged query parameters after cleanup - def query(params, output = nil) + def query(params) raise SerpApiError, "params must be hash, not: #{params.class}" unless params.instance_of?(Hash) # merge default params with custom params q = @params.clone.merge(params) - q.reject! { |key, _| key.to_s == 'output' } if output - q[:output] = output if output # do not pollute default params with custom params q.delete(:symbolize_names) if q.key?(:symbolize_names) @@ -236,11 +234,15 @@ def get(endpoint, decoder = :json, params = {}, output: nil) end def execute_request(endpoint, params, output = nil) + request_params = query(params) + request_params.reject! { |key, _| key.to_s == 'output' } if output + request_params[:output] = output if output + if persistent? - @socket.get(endpoint, params: query(params, output)) + @socket.get(endpoint, params: request_params) else url = "https://#{BACKEND}#{endpoint}" - HTTP.timeout(timeout).get(url, params: query(params, output)) + HTTP.timeout(timeout).get(url, params: request_params) end end From c6b107512aa57351327792be2880a2965f3609e9 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Thu, 20 Aug 2026 12:45:53 +0200 Subject: [PATCH 12/13] Do not change query signature --- lib/serpapi/client.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index dcc58d7..8f820ff 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -123,7 +123,8 @@ def search(params = {}) # # @return [String] raw HTML search results. def html(params = {}) - get('/search', :html, params, output: 'html') + params = params.reject { |key, _| key.to_s == 'output' }.merge(output: 'html') if params.instance_of?(Hash) + get('/search', :html, params) end # Perform a search using SerpApi.com and return results optimized for LLMs and AI agents. @@ -132,7 +133,8 @@ def html(params = {}) # @param [Hash] params includes engine, api_key, search fields and more. # @return [String] search results formatted as Markdown. def md(params = {}) - get('/search', :md, params, output: 'md') + params = params.reject { |key, _| key.to_s == 'output' }.merge(output: 'md') if params.instance_of?(Hash) + get('/search', :md, params) end # Get location using Location API @@ -207,6 +209,8 @@ def query(params) # merge default params with custom params q = @params.clone.merge(params) + q.delete('output') if params.key?(:output) + q.delete(:output) if params.key?('output') && !params.key?(:output) # do not pollute default params with custom params q.delete(:symbolize_names) if q.key?(:symbolize_names) @@ -226,23 +230,19 @@ def persistent? # @param [Symbol] decoder type :json, :html, or :md # @param [Hash] params custom search inputs # @return [String|Hash] raw text or decoded response as JSON / Hash - def get(endpoint, decoder = :json, params = {}, output: nil) - response = execute_request(endpoint, params, output) + def get(endpoint, decoder = :json, params = {}) + response = execute_request(endpoint, params) handle_response(response, response_decoder(response, decoder), endpoint, params) ensure response&.flush if persistent? end - def execute_request(endpoint, params, output = nil) - request_params = query(params) - request_params.reject! { |key, _| key.to_s == 'output' } if output - request_params[:output] = output if output - + def execute_request(endpoint, params) if persistent? - @socket.get(endpoint, params: request_params) + @socket.get(endpoint, params: query(params)) else url = "https://#{BACKEND}#{endpoint}" - HTTP.timeout(timeout).get(url, params: request_params) + HTTP.timeout(timeout).get(url, params: query(params)) end end From 1c351bccb7bb8aded906d1d4f8f831ffea008b70 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Thu, 20 Aug 2026 12:51:27 +0200 Subject: [PATCH 13/13] Update spec --- spec/serpapi/client/client_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/serpapi/client/client_spec.rb b/spec/serpapi/client/client_spec.rb index 17aa119..8384d3b 100644 --- a/spec/serpapi/client/client_spec.rb +++ b/spec/serpapi/client/client_spec.rb @@ -120,7 +120,7 @@ begin client.send(:get, '/invalid', :json, {}) rescue SerpApi::SerpApiError => e - expect(e.message).to include('JSON parse error') + expect(e.message).to include('HTTP request failed with status: 404') rescue => e raise("wrong exception: #{e}") end