diff --git a/README.md b/README.md index 749c080..42bda3a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/index.rst b/docs/index.rst index 717c69a..de3b83b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 @@ -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 diff --git a/serpapi/core.py b/serpapi/core.py index 7d4454a..0e0f96f 100644 --- a/serpapi/core.py +++ b/serpapi/core.py @@ -1,3 +1,6 @@ +import io +import os + from .http import HTTPClient from .exceptions import SearchIDNotProvided from .models import SerpResults @@ -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. @@ -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 diff --git a/serpapi/http.py b/serpapi/http.py index 16da0da..4ea1a33 100644 --- a/serpapi/http.py +++ b/serpapi/http.py @@ -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. diff --git a/tests/test_image_upload.py b/tests/test_image_upload.py new file mode 100644 index 0000000..3ed5a96 --- /dev/null +++ b/tests/test_image_upload.py @@ -0,0 +1,101 @@ +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_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)