diff --git a/README.md b/README.md index 1943027..ef4cd3d 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,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. - JSON responses as Ruby hashes with `search`, 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). +- SDK methods for the [Image API](https://serpapi.com/image-api), [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. ## Configuration @@ -186,6 +186,21 @@ pp results[:images_results] A [light variant](https://serpapi.com/google-images-light-api) engine called `google_images_light` is also available for faster, lower-cost image searches. +### Google Lens with File Upload + +Upload an image with the Image API, then use its image ID in a Google Lens search. Uploaded image IDs expire after 10 minutes. + +```ruby +require 'serpapi' + +client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY']) +upload = client.upload_image('./image.jpg') +results = client.search(engine: 'google_lens', image_id: upload[:image_id]) +pp results[:visual_matches] +``` + +[See Image API documentation](https://serpapi.com/image-api) · [See Google Lens image upload documentation](https://serpapi.com/google-lens-upload-an-image) + ### Google Trends Track search interest over time and compare the popularity of search terms. @@ -332,6 +347,6 @@ The older library (google-search-results-ruby) was performing at 55 req/s on Rub ## Contributing -Contributions are welcome. Make sure to read our [contributing guide](./CONTRIBUTING.md). +Contributions are welcome. Make sure to read our [contributing guide](https://github.com/serpapi/serpapi-ruby/blob/master/CONTRIBUTING.md). © 2026 [SerpApi](https://serpapi.com) diff --git a/README.md.erb b/README.md.erb index 200bc5d..ef4cd3d 100644 --- a/README.md.erb +++ b/README.md.erb @@ -56,7 +56,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. - JSON responses as Ruby hashes with `search`, 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). +- SDK methods for the [Image API](https://serpapi.com/image-api), [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. ## Configuration @@ -186,6 +186,21 @@ pp results[:images_results] A [light variant](https://serpapi.com/google-images-light-api) engine called `google_images_light` is also available for faster, lower-cost image searches. +### Google Lens with File Upload + +Upload an image with the Image API, then use its image ID in a Google Lens search. Uploaded image IDs expire after 10 minutes. + +```ruby +require 'serpapi' + +client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY']) +upload = client.upload_image('./image.jpg') +results = client.search(engine: 'google_lens', image_id: upload[:image_id]) +pp results[:visual_matches] +``` + +[See Image API documentation](https://serpapi.com/image-api) · [See Google Lens image upload documentation](https://serpapi.com/google-lens-upload-an-image) + ### Google Trends Track search interest over time and compare the popularity of search terms. diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 0a8f081..b37075c 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -8,6 +8,7 @@ module SerpApi # * async non-block search # * persistent HTTP connection # * search API + # * image API # * location API # * account API # * search archive API @@ -134,6 +135,25 @@ def location(params = {}) get('/locations.json', :json, params) end + # Upload an image using the Image API. + # The returned image ID can be supplied to Search API engines that support + # uploaded images, such as Google Lens. Image IDs expire after 10 minutes. + # + # doc: https://serpapi.com/image-api + # + # @param [String, Pathname, IO] image path or readable image stream + # @param [Hash] params request parameters, such as an API key overriding the client default + # @return [Hash] upload result containing the image ID + def upload_image(image, params = {}) + if image.is_a?(String) || (defined?(Pathname) && image.is_a?(Pathname)) + ::File.open(image, 'rb') do |file| + post('/image', :json, params, image: HTTP::FormData::File.new(file)) + end + else + post('/image', :json, params, image: HTTP::FormData::File.new(image).tap(&:rewind)) + end + end + # Retrieve search result from the Search Archive API # # ```ruby @@ -215,17 +235,26 @@ def persistent? # @param [Hash] params custom search inputs # @return [String|Hash] raw HTML or decoded response as JSON / Hash def get(endpoint, decoder = :json, params = {}) - response = execute_request(endpoint, params) + response = execute_request(:get, endpoint, params: query(params)) handle_response(response, decoder, endpoint, params) end - def execute_request(endpoint, params) - if persistent? - @socket.get(endpoint, params: query(params)) - else - url = "https://#{BACKEND}#{endpoint}" - HTTP.timeout(timeout).get(url, params: query(params)) - end + # Perform an HTTP POST request with a multipart/form-data body + # + # @param [String] endpoint relative SerpApi endpoint, such as "/image" + # @param [Symbol] decoder response decoder, either :json or :html + # @param [Hash] params request parameters + # @param [Hash] form multipart-specific fields, including uploaded files + # @return [String|Hash] raw HTML or decoded response as JSON / Hash + def post(endpoint, decoder = :json, params = {}, form = {}) + response = execute_request(:post, endpoint, form: query(params).merge(form)) + handle_response(response, decoder, endpoint, params) + end + + def execute_request(method, endpoint, options) + client = persistent? ? @socket : HTTP.timeout(timeout) + url = persistent? ? endpoint : "https://#{BACKEND}#{endpoint}" + client.public_send(method, url, **options) end def handle_response(response, decoder, endpoint, params) @@ -283,7 +312,7 @@ def raise_http_error(response, data, endpoint, params, explicit_error: nil, deco def raise_parser_error(response, endpoint, params) raise SerpApiError.new( - "JSON parse error: #{response.body} on get url: https://#{BACKEND}#{endpoint}", + "JSON parse error: #{response.body} from url: https://#{BACKEND}#{endpoint}", search_params: params, response_status: response.status, decoder: :json diff --git a/spec/serpapi/client/image_api_spec.rb b/spec/serpapi/client/image_api_spec.rb new file mode 100644 index 0000000..0fc4f5d --- /dev/null +++ b/spec/serpapi/client/image_api_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' +require 'stringio' +require 'tempfile' + +describe 'Image API' do + let(:response) do + double( + status: 200, + body: '{"message":"Image uploaded successfully.","image_id":"test-image-id"}' + ) + end + let(:socket) { double } + let(:client) { SerpApi::Client.new(api_key: api_key) } + + before do + allow(HTTP).to receive(:persistent).and_return(socket) + allow(response).to receive(:flush) + end + + it 'uploads an image from a file path' do + Tempfile.create(['image', '.png']) do |image| + expect(socket).to receive(:post) do |endpoint, options| + uploaded_image = options[:form][:image] + + expect(endpoint).to eq('/image') + expect(uploaded_image.filename).to end_with('.png') + response + end + + result = client.upload_image(image.path) + + expect(result[:message]).to eq('Image uploaded successfully.') + expect(result[:image_id]).to eq('test-image-id') + end + end + + it 'rewinds and uploads an image from an IO object' do + image = StringIO.new('image data') + image.read(5) + + expect(socket).to receive(:post) do |endpoint, options| + expect(endpoint).to eq('/image') + expect(image.pos).to eq(0) + response + end + + result = client.upload_image(image) + + expect(result[:image_id]).to eq('test-image-id') + end + + it 'raises an error when image is rejected' do + error_response = double( + status: 400, + body: '{"error":"Invalid image format. Supported format: jpg, jpeg, png, webp"}' + ) + allow(socket).to receive(:post).with('/image', anything).and_return(error_response) + + expect { + client.upload_image(StringIO.new('invalid image data')) + }.to raise_error( + SerpApi::SerpApiError, + /Invalid image format\. Supported format: jpg, jpeg, png, webp/ + ) + end +end