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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,38 @@ results = client.search({
```
- API Documentation: [serpapi.com/google-reverse-image](https://serpapi.com/google-reverse-image)

### Search Google Lens by image URL or upload

Google Lens accepts either a publicly accessible image URL or an uploaded
image. To search by URL, pass the URL directly:

```python
import os
import serpapi

client = serpapi.Client(api_key=os.getenv("SERPAPI_KEY"))
results = client.search({
"engine": "google_lens",
"url": "https://i.imgur.com/HBrB8p0.png",
})
```

To search a local image, upload it first and pass its temporary `image_id` to
Google Lens:

```python
upload = client.upload_image("/path/to/image.png")
results = client.search({
"engine": "google_lens",
"image_id": upload["image_id"],
})
```

Uploaded images can be JPG/JPEG, PNG, or WebP files up to 500 KB. The returned
`image_id` expires after 10 minutes.

- API Documentation: [Google Lens image uploads](https://serpapi.com/google-lens-upload-an-image), [Image API](https://serpapi.com/image-api)

### Search Google Events
```python
import os
Expand Down
2 changes: 2 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ This part of the documentation covers all the interfaces of :class:`serpapi` Pyt

.. autofunction:: serpapi.search
.. autofunction:: serpapi.search_archive
.. autofunction:: serpapi.upload_image
.. autofunction:: serpapi.locations
.. autofunction:: serpapi.account

Expand Down Expand Up @@ -159,6 +160,7 @@ This class also alleviates the need to pass an ``api_key``` along with every se

.. automethod:: Client.search
.. automethod:: Client.search_archive
.. automethod:: Client.upload_image
.. automethod:: Client.account
.. automethod:: Client.locations

Expand Down
51 changes: 51 additions & 0 deletions serpapi/core.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import io
import os

from .http import HTTPClient
from .exceptions import SearchIDNotProvided
from .models import SerpResults
Expand Down Expand Up @@ -108,6 +111,53 @@ def search_archive(self, params: dict = None, **kwargs):
r = self.request("GET", f"/searches/{ search_id }", params=params, **request_kwargs)
return SerpResults.from_http_response(r, client=self)

def upload_image(self, image, **kwargs):
"""Upload an image to SerpApi's Image API.

``image`` can be a filesystem path or an open binary file object. The
returned dictionary contains an ``image_id`` that can be passed to
:meth:`search` for engines that accept uploaded images, such as Google
Lens.

:param image: a path or open binary file object containing a JPG/JPEG,
PNG, or WebP image no larger than 500 KB.
:param api_key: the API Key to use for SerpApi.com.
:param **: any additional multipart form fields to pass to the API.

**Learn more**: https://serpapi.com/image-api
"""
request_kwargs = {}
for key in ["timeout", "proxies", "verify", "stream", "cert"]:
if key in kwargs:
request_kwargs[key] = kwargs.pop(key)

data = kwargs
if "api_key" not in data:
data["api_key"] = self.api_key

image_file = None
try:
if isinstance(image, (str, os.PathLike)):
image_file = open(image, "rb")
image = image_file
elif isinstance(image, io.TextIOBase):
raise TypeError(
"image file must be opened in binary mode, e.g. open(path, 'rb')"
)

r = self.request(
"POST",
"/image",
params={},
data=data,
files={"image": image},
**request_kwargs,
)
return r.json()
finally:
if image_file is not None:
image_file.close()

def locations(self, params: dict = None, **kwargs):
"""Get a list of supported Google locations.

Expand Down Expand Up @@ -168,5 +218,6 @@ def account(self, params: dict = None, **kwargs):
_client = Client()
search = _client.search
search_archive = _client.search_archive
upload_image = _client.upload_image
locations = _client.locations
account = _client.account
4 changes: 3 additions & 1 deletion serpapi/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ def __init__(self, *, api_key=None, timeout=None):

def request(self, method, path, params, *, assert_200=True, **kwargs):
# Inject the API Key into the params.
if "api_key" not in params:
request_data = kwargs.get("data")
api_key_in_data = isinstance(request_data, dict) and "api_key" in request_data
if "api_key" not in params and not api_key_in_data:
params["api_key"] = self.api_key

# Build the URL, as needed.
Expand Down
123 changes: 123 additions & 0 deletions tests/test_image_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from io import BytesIO, StringIO
from unittest.mock import Mock

import pytest
import requests

import serpapi


def json_response(data):
response = requests.Response()
response.status_code = 200
response._content = data
return response


def test_upload_image_path_sends_multipart_request(tmp_path):
image_path = tmp_path / "test.png"
image_path.write_bytes(b"fake-png-data")
client = serpapi.Client(api_key="test-api-key")

def request(**kwargs):
assert kwargs["method"] == "POST"
assert kwargs["url"] == "https://serpapi.com/image"
assert kwargs["params"] == {}
assert kwargs["data"] == {"api_key": "test-api-key"}
assert kwargs["files"]["image"].name == str(image_path)
assert kwargs["files"]["image"].read() == b"fake-png-data"
return json_response(
b'{"message": "Image uploaded successfully.", "image_id": "image-123"}'
)

client.session.request = Mock(side_effect=request)

result = client.upload_image(image_path)

assert result["image_id"] == "image-123"


def test_upload_image_accepts_open_binary_file_and_request_options():
image = BytesIO(b"fake-image-data")
client = serpapi.Client(api_key="client-api-key", timeout=10)
client.session.request = Mock(
return_value=json_response(b'{"image_id": "image-456"}')
)

result = client.upload_image(
image,
api_key="request-api-key",
timeout=5,
zero_trace="true",
)

assert result == {"image_id": "image-456"}
assert not image.closed
_, request_kwargs = client.session.request.call_args
assert request_kwargs["params"] == {}
assert request_kwargs["data"] == {
"api_key": "request-api-key",
"zero_trace": "true",
}
assert request_kwargs["files"] == {"image": image}
assert request_kwargs["timeout"] == 5


def test_upload_image_rejects_text_mode_file(tmp_path):
image_path = tmp_path / "test.png"
image_path.write_text("not binary image data")
client = serpapi.Client(api_key="test-api-key")
client.session.request = Mock()

with image_path.open("r") as image:
with pytest.raises(TypeError, match="opened in binary mode"):
client.upload_image(image)

client.session.request.assert_not_called()


def test_upload_image_rejects_string_io():
client = serpapi.Client(api_key="test-api-key")
client.session.request = Mock()

with pytest.raises(TypeError, match="opened in binary mode"):
client.upload_image(StringIO("not binary image data"))

client.session.request.assert_not_called()


def test_google_lens_search_supports_url_and_uploaded_image_id():
client = serpapi.Client(api_key="test-api-key")
client.session.request = Mock(
side_effect=[
json_response(b'{"image_id": "uploaded-image-123"}'),
json_response(b'{"search_metadata": {"status": "Success"}}'),
json_response(b'{"search_metadata": {"status": "Success"}}'),
]
)

upload = client.upload_image(BytesIO(b"fake-image-data"))
client.search(engine="google_lens", image_id=upload["image_id"])
client.search(engine="google_lens", url="https://example.com/image.png")

upload_search = client.session.request.call_args_list[1][1]["params"]
url_search = client.session.request.call_args_list[2][1]["params"]
assert upload_search["image_id"] == "uploaded-image-123"
assert "url" not in upload_search
assert url_search["url"] == "https://example.com/image.png"
assert "image_id" not in url_search


def test_request_injects_api_key_when_form_data_does_not_include_it():
client = serpapi.Client(api_key="test-api-key")
client.session.request = Mock(return_value=json_response(b"{}"))

client.request("POST", "/example", params={}, data={"field": "value"})

_, request_kwargs = client.session.request.call_args
assert request_kwargs["params"] == {"api_key": "test-api-key"}
assert request_kwargs["data"] == {"field": "value"}


def test_module_exposes_upload_image_entrypoint():
assert callable(serpapi.upload_image)
Loading