From cea26e400a5f34e29cd2ca7f9bdd2a531d163217 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Mon, 17 Aug 2026 10:11:39 +0200 Subject: [PATCH] Add initial Markdown support --- CHANGELOG.md | 1 + README.md | 6 ++++ README.md.erb | 6 ++++ 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, 81 insertions(+), 10 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 69d5fd4..fd38fff 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ SerpApi supports Google, Google Maps, Google Shopping, Baidu, Yandex, Yahoo, eBa Query a vast range of data at scale, including web search results, flight schedules, stock market data, news headlines, and [more](https://serpapi.com). ## Features + * `markdown` → Return token-efficient Markdown output optimized for LLMs and AI agents * `persistent` → Keep socket connection open to save on SSL handshake / reconnection (2x faster). [Search at scale](#Search-At-Scale) * `async` → Support non-blocking job submission. [Search Asynchronous](#Search-Asynchronous) * extensive documentation → easy to follow @@ -42,6 +43,7 @@ pp results ``` This example runs a search for "coffee" on Google. It then returns the results as a regular Ruby Hash. +Use `client.markdown(q: "coffee")` to return the same search as a token-efficient Markdown string optimized for LLMs and AI agents. See the [playground](https://serpapi.com/playground) to generate your own code. The SerpApi key can be obtained from [serpapi.com/signup](https://serpapi.com/users/sign_up?plan=free). @@ -95,6 +97,9 @@ params = { # search results as a symbolized Hash (per performance) results = client.search(params) +# search results as a token-efficient Markdown string for LLMs and AI agents +markdown = client.markdown(params) + # search results as a raw HTML string raw_html = client.html(params) ``` @@ -287,6 +292,7 @@ Now we can retrieve the previous search results from the archive using the searc require 'serpapi' client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY']) results = client.search_archive(search_id) +markdown = client.search_archive(search_id, :markdown) pp results ``` diff --git a/README.md.erb b/README.md.erb index d0bc70a..83466e2 100644 --- a/README.md.erb +++ b/README.md.erb @@ -9,6 +9,7 @@ SerpApi supports Google, Google Maps, Google Shopping, Baidu, Yandex, Yahoo, eBa Query a vast range of data at scale, including web search results, flight schedules, stock market data, news headlines, and [more](https://serpapi.com). ## Features + * `markdown` → Return token-efficient Markdown output optimized for LLMs and AI agents * `persistent` → Keep socket connection open to save on SSL handshake / reconnection (2x faster). [Search at scale](#Search-At-Scale) * `async` → Support non-blocking job submission. [Search Asynchronous](#Search-Asynchronous) * extensive documentation → easy to follow @@ -42,6 +43,7 @@ pp results ``` This example runs a search for "coffee" on Google. It then returns the results as a regular Ruby Hash. +Use `client.markdown(q: "coffee")` to return the same search as a token-efficient Markdown string optimized for LLMs and AI agents. See the [playground](https://serpapi.com/playground) to generate your own code. The SerpApi key can be obtained from [serpapi.com/signup](https://serpapi.com/users/sign_up?plan=free). @@ -95,6 +97,9 @@ params = { # search results as a symbolized Hash (per performance) results = client.search(params) +# search results as a token-efficient Markdown string for LLMs and AI agents +markdown = client.markdown(params) + # search results as a raw HTML string raw_html = client.html(params) ``` @@ -287,6 +292,7 @@ Now we can retrieve the previous search results from the archive using the searc require 'serpapi' client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY']) results = client.search_archive(search_id) +markdown = client.search_archive(search_id, :markdown) pp results ``` 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