Skip to content
Merged
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The following services are provided:
* [Location API](https://serpapi.com/locations-api)
* [Search Archive API](https://serpapi.com/search-archive-api)
* [Account API](https://serpapi.com/account-api)
* [Image API](https://serpapi.com/image-api)

SerpApi provides a [script builder](https://serpapi.com/playground) to get you started quickly.

Expand Down Expand Up @@ -328,6 +329,8 @@ print_r($results->visual_matches);
* source: [tests/ExampleSearchGoogleLensTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleLensTest.php)
see: [https://serpapi.com/google-lens-api](https://serpapi.com/google-lens-api)

To search with a local image instead of a URL, upload it with the [Image API](#image-api).

### Search Google Play
```php
use SerpApi\Client;
Expand Down Expand Up @@ -907,6 +910,27 @@ $account = $client->account();
print_r($account);
```

### Image API

Upload a local image, then search with its `image_id` on engines that accept uploaded images, such as Google Lens:

```php
use SerpApi\Client;

$client = new Client(getenv('SERPAPI_KEY'));
$upload = $client->uploadImage('path/to/image.jpg');

$results = $client->search([
'engine' => 'google_lens',
'image_id' => $upload->image_id,
]);
print_r($results->visual_matches);
```

`uploadImage` also accepts an open stream, such as `fopen('path/to/image.jpg', 'rb')`. Images must be JPG, PNG, or WebP files up to 500 KB, and an `image_id` expires after 10 minutes.

see: [https://serpapi.com/image-api](https://serpapi.com/image-api) and [https://serpapi.com/google-lens-upload-an-image](https://serpapi.com/google-lens-upload-an-image)

### HTML results

```php
Expand Down
24 changes: 24 additions & 0 deletions README.md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ The following services are provided:
* [Location API](https://serpapi.com/locations-api)
* [Search Archive API](https://serpapi.com/search-archive-api)
* [Account API](https://serpapi.com/account-api)
* [Image API](https://serpapi.com/image-api)

SerpApi provides a [script builder](https://serpapi.com/playground) to get you started quickly.

Expand Down Expand Up @@ -250,6 +251,8 @@ see: [https://serpapi.com/google-events-api](https://serpapi.com/google-events-a
<%= snippet('tests/ExampleSearchGoogleLensTest.php') %>
see: [https://serpapi.com/google-lens-api](https://serpapi.com/google-lens-api)

To search with a local image instead of a URL, upload it with the [Image API](#image-api).

### Search Google Play
<%= snippet('tests/ExampleSearchGooglePlayTest.php') %>
see: [https://serpapi.com/google-play-api](https://serpapi.com/google-play-api)
Expand Down Expand Up @@ -478,6 +481,27 @@ $account = $client->account();
print_r($account);
```

### Image API

Upload a local image, then search with its `image_id` on engines that accept uploaded images, such as Google Lens:

```php
use SerpApi\Client;

$client = new Client(getenv('SERPAPI_KEY'));
$upload = $client->uploadImage('path/to/image.jpg');

$results = $client->search([
'engine' => 'google_lens',
'image_id' => $upload->image_id,
]);
print_r($results->visual_matches);
```

`uploadImage` also accepts an open stream, such as `fopen('path/to/image.jpg', 'rb')`. Images must be JPG, PNG, or WebP files up to 500 KB, and an `image_id` expires after 10 minutes.

see: [https://serpapi.com/image-api](https://serpapi.com/image-api) and [https://serpapi.com/google-lens-upload-an-image](https://serpapi.com/google-lens-upload-an-image)

### HTML results

```php
Expand Down
149 changes: 142 additions & 7 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,73 @@ public function location(array $params = []): array
return $this->get('/locations.json', 'json', $params);
}

/**
* Upload an image using the Image API.
*
* The returned `image_id` can be passed to Search API engines that accept
* uploaded images, such as Google Lens. Image IDs expire after 10 minutes.
*
* The upload carries the same parameters as any other request, including
* default search parameters set on the client, plus the image.
*
* @param string|resource $image Path to a JPG, PNG, or WebP file, or a readable stream;
* seekable streams are uploaded from the beginning
* @param array<string, mixed> $params Request parameters, such as `api_key` or `timeout`
* @throws SerpApiException
*/
public function uploadImage($image, array $params = []): object
{
if (is_resource($image) && get_resource_type($image) === 'stream') {
$copy = self::copyToTempFile($image);

try {
$path = stream_get_meta_data($copy)['uri'];
return $this->post('/image', $params, ['image' => new \CURLFile($path, '', 'image')]);
} finally {
fclose($copy);
}
}

if (!is_string($image) || $image === '') {
throw new SerpApiException('image must be a file path or a stream');
}

if (!is_file($image) || !is_readable($image)) {
throw new SerpApiException("image is not a readable file: {$image}");
}

// Without a post name, cURL would send the full local path as the file name.
return $this->post('/image', $params, ['image' => new \CURLFile($image, '', basename($image))]);
}

/**
* cURL uploads files by path, and CURLStringFile requires PHP 8.1, so a
* stream is uploaded from a temporary copy that is deleted once closed.
*
* @param resource $stream
* @return resource
* @throws SerpApiException
*/
private static function copyToTempFile($stream)
{
$copy = tmpfile();
if ($copy === false) {
throw new SerpApiException('Failed to create a temporary file for the image');
}

if (stream_get_meta_data($stream)['seekable']) {
rewind($stream);
}

$copied = stream_copy_to_stream($stream, $copy);
if ($copied === false || $copied === 0 || !fflush($copy)) {
fclose($copy);
throw new SerpApiException('Failed to read the image stream');
}

return $copy;
}

/**
* Retrieve search result from the Search Archive API.
*
Expand Down Expand Up @@ -347,6 +414,31 @@ public function searchArchive(string $searchId, string $format = 'json')
* @throws SerpApiException
*/
private function get(string $endpoint, string $format = 'json', array $params = [], bool $assoc = false)
{
return $this->send($endpoint, $format, $params, $assoc);
}

/**
* Send a multipart/form-data POST. The request parameters travel as form
* fields next to the files instead of in the URL.
*
* @param array<string, mixed> $params
* @param array<string, \CURLFile> $files
* @throws SerpApiException
*/
private function post(string $endpoint, array $params, array $files): object
{
return $this->send($endpoint, 'json', $params, false, $files);
}

/**
* @param array<string, mixed> $params
* @param bool $assoc Decode JSON objects to associative arrays instead of stdClass
* @param array<string, \CURLFile>|null $files Files to POST, or null for a GET
* @return object|array<int|string, mixed>|string
* @throws SerpApiException
*/
private function send(string $endpoint, string $format, array $params, bool $assoc, ?array $files = null)
{
if (!in_array($format, ['json', 'html', 'md'], true)) {
throw new SerpApiException("Unsupported format '$format'. Expected 'html', 'json', or 'md'.");
Expand All @@ -367,9 +459,13 @@ private function get(string $endpoint, string $format = 'json', array $params =

$query = $this->buildQuery($params, (string) $apiKey, $format);

$url = self::BASE_URL . $endpoint . '?' . http_build_query($query);
if ($files === null) {
$requestResult = $this->request(self::BASE_URL . $endpoint . '?' . http_build_query($query), $timeout);
} else {
$form = array_merge(self::formFields($query), $files);
$requestResult = $this->request(self::BASE_URL . $endpoint, $timeout, $form);
}

$requestResult = $this->request($url, $timeout);
$response = $requestResult['response'];
$httpCode = $requestResult['http_code'];
$curlError = $requestResult['curl_error'];
Expand Down Expand Up @@ -457,26 +553,65 @@ private function buildQuery(array $params, string $apiKey, string $format): arra
}

/**
* Perform the HTTP GET request.
* Encode request parameters as form fields exactly as they would be in a
* query string. cURL would otherwise send false as an empty string and
* nested arrays as "Array".
*
* @param array<string, mixed> $query
* @return array<string, string>
*/
private static function formFields(array $query): array
{
$fields = [];

foreach (explode('&', http_build_query($query, '', '&')) as $pair) {
if ($pair === '') {
continue;
}

$parts = explode('=', $pair, 2);
$fields[urldecode($parts[0])] = urldecode($parts[1] ?? '');
}

return $fields;
}

/**
* Perform the HTTP request: a GET, or a multipart/form-data POST when
* form fields are given.
*
* @internal Seam for tests to observe requests without network access;
* not covered by the backwards compatibility promise.
*
* @param array<string, string|\CURLFile>|null $form
* @return array{response: string|false, http_code: int, curl_error: string}
* @throws SerpApiException
*/
protected function request(string $url, int $timeout): array
protected function request(string $url, int $timeout, ?array $form = null): array
{
$ch = $this->acquireHandle();

try {
$isConfigured = curl_setopt_array($ch, [
$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => 'serpapi-php/' . self::VERSION,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $timeout,
]);
CURLOPT_HTTPHEADER => [],
];

// A shared handle keeps the method and headers of the previous request.
if ($form === null) {
$options[CURLOPT_HTTPGET] = true;
} else {
$options[CURLOPT_POSTFIELDS] = $form;
// libcurl before 7.69 sends `Expect: 100-continue` with bodies over
// 1 KB. SerpApi does not answer it, stalling each upload for a second.
$options[CURLOPT_HTTPHEADER] = ['Expect:'];
}

$isConfigured = curl_setopt_array($ch, $options);

if ($isConfigured === false) {
throw new SerpApiException('Failed to configure cURL options: ' . curl_error($ch));
Expand Down Expand Up @@ -588,7 +723,7 @@ private function raiseParserError(
$sanitizedSearchParams = $this->sanitizeSearchParams($searchParams);

throw new SerpApiException(
'JSON parse error: ' . json_last_error_msg() . ' on get url: ' . self::BASE_URL . $endpoint,
'JSON parse error: ' . json_last_error_msg() . ' from url: ' . self::BASE_URL . $endpoint,
null,
$sanitizedSearchParams,
$responseStatus,
Expand Down
15 changes: 15 additions & 0 deletions tests/ClientIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,19 @@ public function testSearchArchive()
$this->assertStringStartsWith('---', $archived_markdown);
$this->assertStringContainsString('## Organic Results', $archived_markdown);
}

public function testUploadImage()
{
$client = $this->serpApiClient();
$upload = $client->uploadImage(__DIR__ . '/fixtures/mona-lisa.jpg');
$this->assertNotEmpty($upload->image_id);

// The search is a GET on the same connection the upload was POSTed on.
$response = $client->search([
'engine' => 'google_lens',
'image_id' => $upload->image_id,
]);
$this->assertEquals('Success', $response->search_metadata->status);
$this->assertResponseHasProperty($response, 'visual_matches');
}
}
Loading
Loading