diff --git a/README.md b/README.md index 8d9dd04..bde373b 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/README.md.erb b/README.md.erb index 834c3b5..5f57a9a 100644 --- a/README.md.erb +++ b/README.md.erb @@ -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). diff --git a/src/Client.php b/src/Client.php index b33278e..b0305ee 100644 --- a/src/Client.php +++ b/src/Client.php @@ -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; @@ -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 $params + * @throws SerpApiException + */ + public function markdown(array $params = []): string { + return $this->get('/search', 'md', $params); + } + /** * Get account information using Account API. * @@ -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); @@ -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; @@ -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; diff --git a/tests/ClientMarkdownTest.php b/tests/ClientMarkdownTest.php new file mode 100644 index 0000000..c4d88b0 --- /dev/null +++ b/tests/ClientMarkdownTest.php @@ -0,0 +1,52 @@ +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()); + } + } + +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php index e25bd00..df63b1e 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -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'); } diff --git a/tests/GoogleSearchTest.php b/tests/GoogleSearchTest.php index 6ba4ac4..0612c9a 100644 --- a/tests/GoogleSearchTest.php +++ b/tests/GoogleSearchTest.php @@ -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();