Repository navigation
Add initial Markdown support #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
379f946
3fb5120
b063cf8
21b5347
d006bcb
086f6f8
3d41fe7
fb033a1
385cab6
39b7e3b
ce01fc3
c6b1075
1c351bc
ea4be3b
10a324d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -7,14 +7,17 @@ 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 | ||||||
| # 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 | ||||||
| attr_reader :timeout, | ||||||
|
|
@@ -108,7 +111,7 @@ 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) | ||||||
| end | ||||||
|
|
@@ -118,11 +121,22 @@ 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] raw HTML search results. | ||||||
| def html(params = {}) | ||||||
| params = params.reject { |key, _| key.to_s == 'output' }.merge(output: 'html') if params.instance_of?(Hash) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Because of this: [1] pry(main)> HashWithIndifferentAccess.new.instance_of?(Hash)
=> false
[2] pry(main)> HashWithIndifferentAccess.new.is_a?(Hash)
=> true |
||||||
| get('/search', :html, params) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @strzibny Thanks for the changes. Output parameter is working now! One issue I observed is that when we do not pass
Suggested change
Other tests I performed are mismatch between output parameter and the method called and it works properly. Its response is similar to SerpApi cURL response - for example if output parameter is json but we call .html then we respond with json which is fine as the output parameter is taking priority like the main SerpApi. |
||||||
| 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 = {}) | ||||||
| params = params.reject { |key, _| key.to_s == 'output' }.merge(output: 'md') if params.instance_of?(Hash) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick: Duplicated with the only difference being the format. Might be worth extracting to a method: def force_output(params, format)
params.reject { |k, _| k.to_s == 'output' }.merge(output: format)
end |
||||||
| get('/search', :md, params) | ||||||
| end | ||||||
|
|
||||||
| # Get location using Location API | ||||||
| # | ||||||
| # example: spec/serpapi/location_api_spec.rb | ||||||
|
|
@@ -146,10 +160,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 | ||||||
|
|
@@ -195,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) | ||||||
|
|
@@ -211,12 +227,14 @@ 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) | ||||||
| handle_response(response, response_decoder(response, decoder), endpoint, params) | ||||||
| ensure | ||||||
| response&.flush if persistent? | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||||||
| end | ||||||
|
|
||||||
| def execute_request(endpoint, params) | ||||||
|
|
@@ -228,14 +246,19 @@ 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 | ||||||
| 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 | ||||||
|
|
||||||
|
|
@@ -249,13 +272,13 @@ def process_json_response(response, endpoint, params) | |||||
| raise_parser_error(response, endpoint, params) | ||||||
| end | ||||||
|
|
||||||
| response.flush if persistent? | ||||||
| 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 | ||||||
|
|
||||||
| response.body.to_s | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||||||
| end | ||||||
|
|
||||||
| def validate_json_content!(data, response, endpoint, params) | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might be worth calling out the behaviour change on
get('/invalid'), which now raises a 404