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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,43 @@ $html = $client->html(['q' => 'Coffee']);
echo strlen($html) . " bytes of HTML\n";
```

### Markdown results

Markdown output is convenient when feeding results to an LLM or indexing
them for RAG, since it carries the structure of the results without the
weight of JSON or HTML.

```php
use SerpApi\Client;

$client = new Client(getenv('SERPAPI_KEY'));
$markdown = $client->markdown(['q' => 'Coffee']);

echo $markdown;
```

The response opens with a YAML front matter block holding `search_metadata`
and `search_parameters`, followed by the results as Markdown sections:

```markdown
---
search_metadata:
id: 68d2f1a4c3b19a7d5e2f0c11
status: Success
---

## Search Information

- Query Displayed: coffee
```

A past search can also be replayed as Markdown through the Search Archive
API, by passing `md` as the format:

```php
$markdown = $client->search_archive($search_id, 'md');
```

## Error handling

`SerpApiException` includes structured context for HTTP and API errors (status code, endpoint, search params, search id).
Expand Down
37 changes: 37 additions & 0 deletions README.md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,43 @@ $html = $client->html(['q' => 'Coffee']);
echo strlen($html) . " bytes of HTML\n";
```

### Markdown results

Markdown output is convenient when feeding results to an LLM or indexing
them for RAG, since it carries the structure of the results without the
weight of JSON or HTML.

```php
use SerpApi\Client;

$client = new Client(getenv('SERPAPI_KEY'));
$markdown = $client->markdown(['q' => 'Coffee']);

echo $markdown;
```

The response opens with a YAML front matter block holding `search_metadata`
and `search_parameters`, followed by the results as Markdown sections:

```markdown
---
search_metadata:
id: 68d2f1a4c3b19a7d5e2f0c11
status: Success
---

## Search Information

- Query Displayed: coffee
```

A past search can also be replayed as Markdown through the Search Archive
API, by passing `md` as the format:

```php
$markdown = $client->search_archive($search_id, 'md');
```

## Error handling

`SerpApiException` includes structured context for HTTP and API errors (status code, endpoint, search params, search id).
Expand Down
35 changes: 29 additions & 6 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ class Client {
/** Client identifier reported to SerpApi for usage statistics. */
const SOURCE = 'serpapi-php:' . self::VERSION;

/** Output formats accepted by the API. */
const FORMATS = ['json', 'html', 'md'];

/** Formats returned to the caller as a raw string rather than decoded. */
const RAW_FORMATS = ['html', 'md'];

/** @var string */
private $api_key;

Expand Down Expand Up @@ -269,6 +275,21 @@ public function html(array $params = []): string {
return $this->get('/search', 'html', $params);
}

/**
* Run a search and return the results as Markdown.
*
* The response opens with a YAML front matter block holding
* `search_metadata` and `search_parameters`, followed by the results as
* Markdown sections. Useful for feeding results to an LLM, or for RAG
* pipelines that index text rather than JSON.
*
* @param array<string, mixed> $params
* @throws SerpApiException
*/
public function markdown(array $params = []): string {
return $this->get('/search', 'md', $params);
}

/**
* Get account information using Account API.
*
Expand Down Expand Up @@ -302,8 +323,8 @@ public function search_archive(string $search_id, string $format = 'json') {
throw new SerpApiException('search_id must be present');
}

if (!in_array($format, ['json', 'html'], true)) {
throw new SerpApiException('format must be json or html');
if (!in_array($format, self::FORMATS, true)) {
throw new SerpApiException('format must be json, html or md');
}

$safe_search_id = rawurlencode($search_id);
Expand All @@ -316,8 +337,10 @@ public function search_archive(string $search_id, string $format = 'json') {
* @throws SerpApiException
*/
private function get(string $endpoint, string $format = 'json', array $params = []) {
if (!in_array($format, ['json', 'html'], true)) {
throw new SerpApiException("Unsupported format '$format'. Expected 'html' or 'json'.");
if (!in_array($format, self::FORMATS, true)) {
throw new SerpApiException(
"Unsupported format '$format'. Expected " . implode(', ', self::FORMATS) . '.'
);
}

$api_key = $params['api_key'] ?? $this->api_key;
Expand All @@ -340,12 +363,12 @@ private function get(string $endpoint, string $format = 'json', array $params =
throw new SerpApiException('cURL error: ' . $curl_error);
}

if ($format === 'html') {
if (in_array($format, self::RAW_FORMATS, true)) {
if ($http_code === 200) {
return $response;
}

$this->raise_http_error($http_code, $endpoint, $query, null, null, 'html');
$this->raise_http_error($http_code, $endpoint, $query, null, null, $format);
}

$assoc = isset($params['assoc']) ? (bool) $params['assoc'] : $this->assoc;
Expand Down
52 changes: 52 additions & 0 deletions tests/ClientMarkdownTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace SerpApi\Tests;

use SerpApi\Client;
use SerpApi\SerpApiException;

/**
* Markdown output. The request building is covered without an API key;
* the live round trip is covered in GoogleSearchTest.
*/
class ClientMarkdownTest extends ClientQueryTest {
public function test_md_is_an_accepted_format() {
$this->assertContains('md', Client::FORMATS);
}

public function test_md_is_returned_raw_like_html() {
$this->assertContains('md', Client::RAW_FORMATS);
$this->assertNotContains('json', Client::RAW_FORMATS);
}

public function test_markdown_requests_output_md() {
$query = $this->query(new Client('secret'), ['q' => 'coffee'], 'secret', 'md');
$this->assertEquals('md', $query['output']);
}

public function test_unsupported_format_is_rejected() {
$this->expectException(SerpApiException::class);
$this->expectExceptionMessage("Unsupported format 'xml'. Expected json, html, md.");

$method = new \ReflectionMethod(Client::class, 'get');
if (PHP_VERSION_ID < 80100) {
$method->setAccessible(true);
}

$method->invoke(new Client('secret'), '/search', 'xml', []);
}

public function test_search_archive_accepts_md() {
// Reaches the network only after validation, so an empty id is enough
// to prove `md` passes the format check while `xml` does not.
$client = new Client('secret');

try {
$client->search_archive('', 'md');
$this->fail('expected an exception');
} catch (SerpApiException $e) {
$this->assertEquals('search_id must be present', $e->getMessage());
}
}

}
2 changes: 1 addition & 1 deletion tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function test_search_archive_throws_when_id_empty() {

public function test_search_archive_throws_when_format_invalid() {
$this->expectException(SerpApiException::class);
$this->expectExceptionMessage('format must be json or html');
$this->expectExceptionMessage('format must be json, html or md');
$client = new Client('test_key');
$client->search_archive('abc', 'xml');
}
Expand Down
17 changes: 17 additions & 0 deletions tests/GoogleSearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ public function test_google_html_returns_html_payload() {
$this->assertGreaterThan(10000, strlen($response));
}

public function test_google_markdown_returns_markdown_payload() {
$client = $this->serpApiClient('google');
$response = $client->markdown($this->search_params);

$this->assertStringStartsWith('---', $response, 'markdown output should open with YAML front matter');
$this->assertStringContainsString('search_metadata:', $response);
$this->assertStringContainsString('## ', $response, 'markdown output should contain headings');
}

public function test_google_search_archive_returns_markdown() {
$client = $this->serpApiClient('google');
$result = $client->search($this->search_params);
$archived = $client->search_archive($result->search_metadata->id, 'md');

$this->assertStringContainsString($result->search_metadata->id, $archived);
}

public function test_google_account_returns_api_key() {
$client = $this->serpApiClient('google');
$info = $client->account();
Expand Down