diff --git a/.github/workflows/serpapi-php.yml b/.github/workflows/serpapi-php.yml index f6de1c5..83cda27 100644 --- a/.github/workflows/serpapi-php.yml +++ b/.github/workflows/serpapi-php.yml @@ -25,6 +25,9 @@ jobs: - name: Run linter run: composer run-script lint + - name: Run static analysis + run: composer run-script analyse + run: runs-on: ubuntu-latest strategy: diff --git a/Makefile b/Makefile index 9cc42ed..6b776af 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ +.PHONY: all clean install lint analyse test readme demo + # Default target -all: install readme lint test +all: install readme lint analyse test # Clean up the project clean: @@ -13,6 +15,10 @@ install: lint: vendor/bin/phpcs +# Run static analysis +analyse: + vendor/bin/phpstan analyse --no-progress --memory-limit=512M + # Run the tests test: vendor/bin/phpunit -c phpunit.xml @@ -20,3 +26,10 @@ test: # Generate README from ERB template readme: erb -T '-' README.md.erb > README.md + +# Run the demo scripts against the live API (requires SERPAPI_KEY) +demo: + @for file in demo/*.php; do \ + echo "running $$file"; \ + php $$file || exit 1; \ + done diff --git a/README.md b/README.md index 36c0eae..2903e82 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,14 @@ Use `search` for structured results decoded into a PHP object: ```php $results = $client->search(['q' => 'coffee']); +echo $results->organic_results[0]->title; +``` + +Use `searchArray` for the same results as an associative array: + +```php +$results = $client->searchArray(['q' => 'coffee']); +echo $results['organic_results'][0]['title']; ``` Use `md` for a token-efficient Markdown string optimized for LLMs and AI agents: @@ -126,6 +134,64 @@ use SerpApi\Client; $client = new Client(getenv('SERPAPI_KEY'), 'google', 30); ``` +Or for a single request: + +```php +$results = $client->search(['q' => 'coffee', 'timeout' => 10]); +``` + +### Default search parameters + +The constructor also accepts a single array. `api_key`, `engine`, `timeout`, and `persistent` configure the client; every other key becomes a default search parameter sent with every request: + +```php +use SerpApi\Client; + +$client = new Client([ + 'api_key' => getenv('SERPAPI_KEY'), + 'engine' => 'google', + 'hl' => 'en', + 'gl' => 'us', +]); + +// q=coffee, hl=en, gl=us +$results = $client->search(['q' => 'coffee']); + +// Per-call parameters take precedence: q=coffee, hl=fr, gl=us +$results = $client->search(['q' => 'coffee', 'hl' => 'fr']); + +// null drops a default for one call: q=coffee, hl=en +$results = $client->search(['q' => 'coffee', 'gl' => null]); +``` + +With positional arguments, pass default search parameters fourth: `new Client($apiKey, 'google', 30, ['hl' => 'en'])`. + +### Persistent connections + +The client keeps its connection to SerpApi open and reuses it, so only the first request pays for the DNS lookup and TLS handshake. The connection closes when the client is destroyed, or explicitly with `close()`: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$client->search(['q' => 'coffee']); +$client->search(['q' => 'tea']); // reuses the connection +$client->close(); +``` + +A closed client reconnects on its next request. To open a new connection for every request instead, pass `'persistent' => false` in the configuration array. [`demo/demo_persistent.php`](demo/demo_persistent.php) compares the two. + +### Debugging + +`inspect()`, `var_dump()`, and `print_r()` mask the API key: + +```php +echo $client->inspect(); +// # +``` + +`var_export()` bypasses the mask and prints the key in clear text. + ## Search API ### Search Google @@ -171,7 +237,7 @@ $results = $client->search([ 'q' => 'coffee', ]); -print_r($results->organic_results); +print_r($results->suggestions); ``` * source: [tests/ExampleSearchGoogleAutocompleteTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleAutocompleteTest.php) @@ -187,7 +253,7 @@ $results = $client->search([ 'q' => 'coffee', ]); -print_r($results->organic_results); +print_r($results->shopping_results); ``` * source: [tests/ExampleSearchGoogleShoppingTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleShoppingTest.php) @@ -238,7 +304,7 @@ $results = $client->search([ 'location' => 'Austin, Texas, United States', ]); -print_r($results->organic_results); +print_r($results->events_results); ``` * source: [tests/ExampleSearchGoogleEventsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleEventsTest.php) @@ -296,6 +362,291 @@ print_r($results->local_ads); * source: [tests/ExampleSearchGoogleLocalServicesTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleLocalServicesTest.php) see: [https://serpapi.com/google-local-services-api](https://serpapi.com/google-local-services-api) +### Search Google Images +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_images', + 'q' => 'coffee', +]); + +print_r($results->images_results); +``` + + * source: [tests/ExampleSearchGoogleImagesTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleImagesTest.php) +see: [https://serpapi.com/google-images-api](https://serpapi.com/google-images-api) + +### Search Google Images Light +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_images_light', + 'q' => 'Coffee', +]); + +print_r($results->images_results); +``` + + * source: [tests/ExampleSearchGoogleImagesLightTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleImagesLightTest.php) +see: [https://serpapi.com/google-images-light-api](https://serpapi.com/google-images-light-api) + +### Search Google News +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_news', + 'q' => 'pizza', + 'gl' => 'us', + 'hl' => 'en', +]); + +print_r($results->news_results); +``` + + * source: [tests/ExampleSearchGoogleNewsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleNewsTest.php) +see: [https://serpapi.com/google-news-api](https://serpapi.com/google-news-api) + +### Search Google News Light +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_news_light', + 'q' => 'pizza', +]); + +print_r($results->news_results); +``` + + * source: [tests/ExampleSearchGoogleNewsLightTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleNewsLightTest.php) +see: [https://serpapi.com/google-news-light-api](https://serpapi.com/google-news-light-api) + +### Search Google Videos +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_videos', + 'q' => 'coffee', +]); + +print_r($results->video_results); +``` + + * source: [tests/ExampleSearchGoogleVideosTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleVideosTest.php) +see: [https://serpapi.com/google-videos-api](https://serpapi.com/google-videos-api) + +### Search Google Light +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_light', + 'q' => 'coffee', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchGoogleLightTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleLightTest.php) +see: [https://serpapi.com/google-light-api](https://serpapi.com/google-light-api) + +### Search Google AI Mode +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_ai_mode', + 'q' => 'best coffee maker', +]); + +print_r($results->reconstructed_markdown); +``` + + * source: [tests/ExampleSearchGoogleAiModeTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleAiModeTest.php) +see: [https://serpapi.com/google-ai-mode-api](https://serpapi.com/google-ai-mode-api) + +### Search Google AI Overview + +The AI Overview API takes a `page_token` from a Google search. Google shows most AI Overviews inline, so the token is only present for some queries, and it expires a minute after the search: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$search = $client->search(['q' => 'how does art work in android']); + +if (isset($search->ai_overview->page_token)) { + $results = $client->search([ + 'engine' => 'google_ai_overview', + 'page_token' => $search->ai_overview->page_token, + ]); + + print_r($results->ai_overview); +} +``` + + * source: [tests/ExampleSearchGoogleAiOverviewTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleAiOverviewTest.php) +see: [https://serpapi.com/google-ai-overview-api](https://serpapi.com/google-ai-overview-api) + +### Search Google Product + +The Product API takes the `immersive_product_page_token` of a Google Shopping result, or a `product_id`: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker']); + +$results = $client->search([ + 'engine' => 'google_product', + 'page_token' => $shopping->shopping_results[0]->immersive_product_page_token, +]); + +print_r($results->product_results); +``` + + * source: [tests/ExampleSearchGoogleProductTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleProductTest.php) +see: [https://serpapi.com/google-product-api](https://serpapi.com/google-product-api) + +### Search Google Immersive Product + +The Immersive Product API takes the `immersive_product_page_token` of a Google Shopping result: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker']); + +$results = $client->search([ + 'engine' => 'google_immersive_product', + 'page_token' => $shopping->shopping_results[0]->immersive_product_page_token, +]); + +print_r($results->product_results); +``` + + * source: [tests/ExampleSearchGoogleImmersiveProductTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleImmersiveProductTest.php) +see: [https://serpapi.com/google-immersive-product-api](https://serpapi.com/google-immersive-product-api) + +### Search Google Flights +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_flights', + 'departure_id' => 'LAX', + 'arrival_id' => 'AUS', + 'outbound_date' => date('Y-m-d', strtotime('+30 days')), + 'return_date' => date('Y-m-d', strtotime('+37 days')), +]); + +print_r($results->best_flights); +``` + + * source: [tests/ExampleSearchGoogleFlightsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleFlightsTest.php) +see: [https://serpapi.com/google-flights-api](https://serpapi.com/google-flights-api) + +### Search Google Hotels +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_hotels', + 'q' => 'Bali Resorts', + 'check_in_date' => date('Y-m-d', strtotime('+30 days')), + 'check_out_date' => date('Y-m-d', strtotime('+31 days')), + 'adults' => '2', + 'currency' => 'USD', + 'gl' => 'us', + 'hl' => 'en', +]); + +print_r($results->properties); +``` + + * source: [tests/ExampleSearchGoogleHotelsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleHotelsTest.php) +see: [https://serpapi.com/google-hotels-api](https://serpapi.com/google-hotels-api) + +### Search Google Finance +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_finance', + 'q' => 'GOOG:NASDAQ', +]); + +print_r($results->summary); +``` + + * source: [tests/ExampleSearchGoogleFinanceTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleFinanceTest.php) +see: [https://serpapi.com/google-finance-api](https://serpapi.com/google-finance-api) + +### Search Google Trends +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_trends', + 'q' => 'coffee,milk,bread,pasta,steak', + 'data_type' => 'TIMESERIES', +]); + +print_r($results->interest_over_time); +``` + + * source: [tests/ExampleSearchGoogleTrendsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleTrendsTest.php) +see: [https://serpapi.com/google-trends-api](https://serpapi.com/google-trends-api) + +### Search Google Patents +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_patents', + 'q' => '(Coffee)', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchGooglePatentsTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGooglePatentsTest.php) +see: [https://serpapi.com/google-patents-api](https://serpapi.com/google-patents-api) + +### Search Google Reverse Image +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'google_reverse_image', + 'image_url' => 'https://i.imgur.com/HBrB8p0.png', +]); + +print_r($results->image_results); +``` + + * source: [tests/ExampleSearchGoogleReverseImageTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleReverseImageTest.php) +see: [https://serpapi.com/google-reverse-image](https://serpapi.com/google-reverse-image) + ### Search Bing ```php use SerpApi\Client; @@ -456,6 +807,55 @@ print_r($results->organic_results); * source: [tests/ExampleSearchDuckduckgoTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchDuckduckgoTest.php) see: [https://serpapi.com/duckduckgo-search-api](https://serpapi.com/duckduckgo-search-api) +### Search Amazon +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'amazon', + 'k' => 'coffee', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchAmazonTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchAmazonTest.php) +see: [https://serpapi.com/amazon-search-api](https://serpapi.com/amazon-search-api) + +### Search Yandex +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'yandex', + 'text' => 'coffee', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchYandexTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchYandexTest.php) +see: [https://serpapi.com/yandex-search-api](https://serpapi.com/yandex-search-api) + +### Search Yelp +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$results = $client->search([ + 'engine' => 'yelp', + 'find_desc' => 'Coffee', + 'find_loc' => 'New York, NY, USA', +]); + +print_r($results->organic_results); +``` + + * source: [tests/ExampleSearchYelpTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchYelpTest.php) +see: [https://serpapi.com/yelp-search-api](https://serpapi.com/yelp-search-api) + ## APIs supported ### Location API @@ -561,6 +961,19 @@ make install make test ``` +Run the linter and static analysis: + +```bash +make lint +make analyse +``` + +The `demo/` directory holds runnable scripts, including a batch of async searches and a persistent connection benchmark: + +```bash +make demo +``` + Contributions are welcome. Feel free to submit a pull request! ## Change log diff --git a/README.md.erb b/README.md.erb index 871e39f..0d99cee 100644 --- a/README.md.erb +++ b/README.md.erb @@ -1,5 +1,5 @@ <%- -def snippet(path) +def snippet(path, property = nil) source = File.read(path) params_match = source.match(/\$this->searchParams\s*=\s*(\[.*?\]);/m) @@ -12,7 +12,7 @@ def snippet(path) (i == 0 || i == raw_lines.length - 1) ? l : " #{l}" }.join("\n") - property = source[/assertResponseHasProperty\(\$response,\s*'([^']+)'/, 1] || 'organic_results' + property ||= source[/assertResponseHasProperty\(\s*\$response,\s*'([^']+)'/, 1] || 'organic_results' code = "use SerpApi\\Client;\n\n" code += "$client = new Client(getenv('SERPAPI_KEY'));\n" @@ -92,6 +92,14 @@ Use `search` for structured results decoded into a PHP object: ```php $results = $client->search(['q' => 'coffee']); +echo $results->organic_results[0]->title; +``` + +Use `searchArray` for the same results as an associative array: + +```php +$results = $client->searchArray(['q' => 'coffee']); +echo $results['organic_results'][0]['title']; ``` Use `md` for a token-efficient Markdown string optimized for LLMs and AI agents: @@ -150,6 +158,64 @@ use SerpApi\Client; $client = new Client(getenv('SERPAPI_KEY'), 'google', 30); ``` +Or for a single request: + +```php +$results = $client->search(['q' => 'coffee', 'timeout' => 10]); +``` + +### Default search parameters + +The constructor also accepts a single array. `api_key`, `engine`, `timeout`, and `persistent` configure the client; every other key becomes a default search parameter sent with every request: + +```php +use SerpApi\Client; + +$client = new Client([ + 'api_key' => getenv('SERPAPI_KEY'), + 'engine' => 'google', + 'hl' => 'en', + 'gl' => 'us', +]); + +// q=coffee, hl=en, gl=us +$results = $client->search(['q' => 'coffee']); + +// Per-call parameters take precedence: q=coffee, hl=fr, gl=us +$results = $client->search(['q' => 'coffee', 'hl' => 'fr']); + +// null drops a default for one call: q=coffee, hl=en +$results = $client->search(['q' => 'coffee', 'gl' => null]); +``` + +With positional arguments, pass default search parameters fourth: `new Client($apiKey, 'google', 30, ['hl' => 'en'])`. + +### Persistent connections + +The client keeps its connection to SerpApi open and reuses it, so only the first request pays for the DNS lookup and TLS handshake. The connection closes when the client is destroyed, or explicitly with `close()`: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$client->search(['q' => 'coffee']); +$client->search(['q' => 'tea']); // reuses the connection +$client->close(); +``` + +A closed client reconnects on its next request. To open a new connection for every request instead, pass `'persistent' => false` in the configuration array. [`demo/demo_persistent.php`](demo/demo_persistent.php) compares the two. + +### Debugging + +`inspect()`, `var_dump()`, and `print_r()` mask the API key: + +```php +echo $client->inspect(); +// # +``` + +`var_export()` bypasses the mask and prints the key in clear text. + ## Search API ### Search Google @@ -192,6 +258,123 @@ see: [https://serpapi.com/google-play-api](https://serpapi.com/google-play-api) <%= snippet('tests/ExampleSearchGoogleLocalServicesTest.php') %> see: [https://serpapi.com/google-local-services-api](https://serpapi.com/google-local-services-api) +### Search Google Images +<%= snippet('tests/ExampleSearchGoogleImagesTest.php') %> +see: [https://serpapi.com/google-images-api](https://serpapi.com/google-images-api) + +### Search Google Images Light +<%= snippet('tests/ExampleSearchGoogleImagesLightTest.php') %> +see: [https://serpapi.com/google-images-light-api](https://serpapi.com/google-images-light-api) + +### Search Google News +<%= snippet('tests/ExampleSearchGoogleNewsTest.php') %> +see: [https://serpapi.com/google-news-api](https://serpapi.com/google-news-api) + +### Search Google News Light +<%= snippet('tests/ExampleSearchGoogleNewsLightTest.php') %> +see: [https://serpapi.com/google-news-light-api](https://serpapi.com/google-news-light-api) + +### Search Google Videos +<%= snippet('tests/ExampleSearchGoogleVideosTest.php') %> +see: [https://serpapi.com/google-videos-api](https://serpapi.com/google-videos-api) + +### Search Google Light +<%= snippet('tests/ExampleSearchGoogleLightTest.php') %> +see: [https://serpapi.com/google-light-api](https://serpapi.com/google-light-api) + +### Search Google AI Mode +<%= snippet('tests/ExampleSearchGoogleAiModeTest.php') %> +see: [https://serpapi.com/google-ai-mode-api](https://serpapi.com/google-ai-mode-api) + +### Search Google AI Overview + +The AI Overview API takes a `page_token` from a Google search. Google shows most AI Overviews inline, so the token is only present for some queries, and it expires a minute after the search: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$search = $client->search(['q' => 'how does art work in android']); + +if (isset($search->ai_overview->page_token)) { + $results = $client->search([ + 'engine' => 'google_ai_overview', + 'page_token' => $search->ai_overview->page_token, + ]); + + print_r($results->ai_overview); +} +``` + + * source: [tests/ExampleSearchGoogleAiOverviewTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleAiOverviewTest.php) +see: [https://serpapi.com/google-ai-overview-api](https://serpapi.com/google-ai-overview-api) + +### Search Google Product + +The Product API takes the `immersive_product_page_token` of a Google Shopping result, or a `product_id`: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker']); + +$results = $client->search([ + 'engine' => 'google_product', + 'page_token' => $shopping->shopping_results[0]->immersive_product_page_token, +]); + +print_r($results->product_results); +``` + + * source: [tests/ExampleSearchGoogleProductTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleProductTest.php) +see: [https://serpapi.com/google-product-api](https://serpapi.com/google-product-api) + +### Search Google Immersive Product + +The Immersive Product API takes the `immersive_product_page_token` of a Google Shopping result: + +```php +use SerpApi\Client; + +$client = new Client(getenv('SERPAPI_KEY')); +$shopping = $client->search(['engine' => 'google_shopping', 'q' => 'coffee maker']); + +$results = $client->search([ + 'engine' => 'google_immersive_product', + 'page_token' => $shopping->shopping_results[0]->immersive_product_page_token, +]); + +print_r($results->product_results); +``` + + * source: [tests/ExampleSearchGoogleImmersiveProductTest.php](https://github.com/serpapi/serpapi-php/blob/master/tests/ExampleSearchGoogleImmersiveProductTest.php) +see: [https://serpapi.com/google-immersive-product-api](https://serpapi.com/google-immersive-product-api) + +### Search Google Flights +<%= snippet('tests/ExampleSearchGoogleFlightsTest.php', 'best_flights') %> +see: [https://serpapi.com/google-flights-api](https://serpapi.com/google-flights-api) + +### Search Google Hotels +<%= snippet('tests/ExampleSearchGoogleHotelsTest.php') %> +see: [https://serpapi.com/google-hotels-api](https://serpapi.com/google-hotels-api) + +### Search Google Finance +<%= snippet('tests/ExampleSearchGoogleFinanceTest.php') %> +see: [https://serpapi.com/google-finance-api](https://serpapi.com/google-finance-api) + +### Search Google Trends +<%= snippet('tests/ExampleSearchGoogleTrendsTest.php') %> +see: [https://serpapi.com/google-trends-api](https://serpapi.com/google-trends-api) + +### Search Google Patents +<%= snippet('tests/ExampleSearchGooglePatentsTest.php') %> +see: [https://serpapi.com/google-patents-api](https://serpapi.com/google-patents-api) + +### Search Google Reverse Image +<%= snippet('tests/ExampleSearchGoogleReverseImageTest.php') %> +see: [https://serpapi.com/google-reverse-image](https://serpapi.com/google-reverse-image) + ### Search Bing <%= snippet('tests/ExampleSearchBingTest.php') %> see: [https://serpapi.com/bing-search-api](https://serpapi.com/bing-search-api) @@ -232,6 +415,18 @@ see: [https://serpapi.com/apple-app-store](https://serpapi.com/apple-app-store) <%= snippet('tests/ExampleSearchDuckduckgoTest.php') %> see: [https://serpapi.com/duckduckgo-search-api](https://serpapi.com/duckduckgo-search-api) +### Search Amazon +<%= snippet('tests/ExampleSearchAmazonTest.php') %> +see: [https://serpapi.com/amazon-search-api](https://serpapi.com/amazon-search-api) + +### Search Yandex +<%= snippet('tests/ExampleSearchYandexTest.php') %> +see: [https://serpapi.com/yandex-search-api](https://serpapi.com/yandex-search-api) + +### Search Yelp +<%= snippet('tests/ExampleSearchYelpTest.php') %> +see: [https://serpapi.com/yelp-search-api](https://serpapi.com/yelp-search-api) + ## APIs supported ### Location API @@ -337,6 +532,19 @@ make install make test ``` +Run the linter and static analysis: + +```bash +make lint +make analyse +``` + +The `demo/` directory holds runnable scripts, including a batch of async searches and a persistent connection benchmark: + +```bash +make demo +``` + Contributions are welcome. Feel free to submit a pull request! ## Change log diff --git a/composer.json b/composer.json index 3cb274b..4498ba5 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,8 @@ }, "require-dev": { "phpunit/phpunit": "^8.5.52 || ^9.6 || ^10.5 || ^11.5 || ^12.5 || ^13.0", - "squizlabs/php_codesniffer": "^3.10" + "squizlabs/php_codesniffer": "^3.10", + "phpstan/phpstan": "^1.12 || ^2.1" }, "autoload": { "psr-4": { @@ -62,6 +63,7 @@ }, "scripts": { "test": "phpunit -c phpunit.xml", - "lint": "phpcs" + "lint": "phpcs", + "analyse": "phpstan analyse --no-progress --memory-limit=512M" } } diff --git a/demo/demo.php b/demo/demo.php new file mode 100644 index 0000000..6eea992 --- /dev/null +++ b/demo/demo.php @@ -0,0 +1,39 @@ + $apiKey, + 'engine' => 'google', +]); + +$results = $client->search(['q' => 'coffee']); + +if (empty($results->organic_results)) { + fwrite(STDERR, "no organic results found\n"); + exit(1); +} + +foreach ($results->organic_results as $result) { + printf("%d. %s\n %s\n", $result->position, $result->title, $result->link); +} + +$client->close(); +echo "done\n"; diff --git a/demo/demo_async.php b/demo/demo_async.php new file mode 100644 index 0000000..a2bd8ee --- /dev/null +++ b/demo/demo_async.php @@ -0,0 +1,73 @@ + true` SerpApi accepts a search and returns right away + * instead of waiting for the results. Submitting a batch first and then + * collecting the results through the Search Archive API is much faster + * than running the searches one after another. + * + * Usage: + * export SERPAPI_KEY="your secret key" + * php demo/demo_async.php + */ + +require __DIR__ . '/../vendor/autoload.php'; + +use SerpApi\Client; +use SerpApi\SerpApiException; + +$apiKey = getenv('SERPAPI_KEY'); +if (empty($apiKey)) { + fwrite(STDERR, "SERPAPI_KEY environment variable must be set\n"); + exit(1); +} + +// One client, and so one open connection, for the whole batch. +$client = new Client(['api_key' => $apiKey, 'engine' => 'google']); + +// `async` is passed per call rather than set on the client, where it would +// also be sent with every Search Archive request below. +$pending = []; +foreach (['meta', 'amazon', 'apple', 'netflix', 'google'] as $company) { + $submitted = $client->search(['q' => $company, 'async' => true]); + $pending[$submitted->search_metadata->id] = $company; + echo "submitted: {$company}\n"; +} + +echo "\ncollecting " . count($pending) . " results\n"; + +$deadline = time() + 120; +while (!empty($pending)) { + if (time() >= $deadline) { + fwrite(STDERR, 'timed out waiting for: ' . implode(', ', $pending) . "\n"); + exit(1); + } + + foreach ($pending as $searchId => $company) { + try { + $archived = $client->searchArchive((string) $searchId); + } catch (SerpApiException $e) { + // A failed search is archived with an `error`, which the client raises. + fwrite(STDERR, "{$company} failed: {$e->getMessage()}\n"); + exit(1); + } + + // Queued or Processing: check again on the next pass. + if ($archived->search_metadata->status !== 'Success') { + continue; + } + + $count = count($archived->organic_results ?? []); + printf(" %-8s %d organic results\n", $company, $count); + unset($pending[$searchId]); + } + + if (!empty($pending)) { + sleep(1); + } +} + +$client->close(); +echo "done\n"; diff --git a/demo/demo_persistent.php b/demo/demo_persistent.php new file mode 100644 index 0000000..0ad75ab --- /dev/null +++ b/demo/demo_persistent.php @@ -0,0 +1,42 @@ +location(['q' => 'Austin', 'limit' => 1]); + + $start = microtime(true); + for ($i = 0; $i < $requests; $i++) { + $client->location(['q' => 'Austin', 'limit' => 1]); + } + + return microtime(true) - $start; +}; + +$without = $benchmark(new Client(['persistent' => false])); +printf("persistent off: %6.0f ms (%.1f req/s)\n", $without * 1000, $requests / $without); + +$client = new Client(['persistent' => true]); +$with = $benchmark($client); +$client->close(); +printf("persistent on: %6.0f ms (%.1f req/s)\n", $with * 1000, $requests / $with); + +printf("\nspeedup: %.1fx\n", $without / $with); +echo "done\n"; diff --git a/demo/demo_suggest.php b/demo/demo_suggest.php new file mode 100644 index 0000000..afdd2ef --- /dev/null +++ b/demo/demo_suggest.php @@ -0,0 +1,47 @@ + $apiKey, + 'engine' => 'google_autocomplete', + 'timeout' => 10, + 'client' => 'safari', + 'hl' => 'en', + 'gl' => 'us', +]); + +foreach (['coffee', 'espresso'] as $query) { + $results = $client->search(['q' => $query]); + + if (empty($results->suggestions)) { + fwrite(STDERR, "no suggestions found for {$query}\n"); + exit(1); + } + + echo "{$query}:\n"; + foreach ($results->suggestions as $suggestion) { + echo " - {$suggestion->value}\n"; + } +} + +echo "done\n"; diff --git a/phpcs.xml b/phpcs.xml index 7ba24af..4a9e47f 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -4,6 +4,7 @@ src tests + demo diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..f0d3853 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 6 + paths: + - src + - demo diff --git a/src/Client.php b/src/Client.php index 156b83c..af3efc8 100644 --- a/src/Client.php +++ b/src/Client.php @@ -8,6 +8,9 @@ class Client public const BASE_URL = 'https://serpapi.com'; public const DEFAULT_TIMEOUT = 120; + /** Client identifier reported to SerpApi in the `source` parameter. */ + public const SOURCE = 'serpapi-php:' . self::VERSION; + /** @var string */ private $apiKey; @@ -17,21 +20,73 @@ class Client /** @var int */ private $timeout; + /** @var array Search parameters merged into every request */ + private $params = []; + + /** @var bool Keep one connection open and reuse it across requests */ + private $persistent = true; + + /** @var resource|\CurlHandle|null Shared cURL handle: a resource on PHP 7, a CurlHandle on PHP 8 */ + private $handle; + /** - * @param string $apiKey + * Accepts positional arguments or, like the Ruby client, a single array + * mixing configuration with default search parameters: + * + * new Client(['api_key' => '...', 'engine' => 'google', 'hl' => 'en']) + * + * `api_key`, `engine`, `timeout` and `persistent` configure the client + * and take precedence over the positional arguments. Every other key + * becomes a default search parameter, sent with every request unless + * overridden per call. + * + * @param string|array $apiKey API key, or a configuration array * @param string $engine * @param int $timeout Request timeout in seconds + * @param array $params Default search parameters and configuration * @throws SerpApiException */ - public function __construct(string $apiKey = '', string $engine = 'google', int $timeout = self::DEFAULT_TIMEOUT) - { + public function __construct( + $apiKey = '', + string $engine = 'google', + int $timeout = self::DEFAULT_TIMEOUT, + array $params = [] + ) { + if (is_array($apiKey)) { + $params = array_merge($apiKey, $params); + $apiKey = ''; + } + + $apiKey = self::pull($params, 'api_key', $apiKey); + $engine = self::pull($params, 'engine', $engine); + $timeout = self::pull($params, 'timeout', $timeout); + $persistent = self::pull($params, 'persistent', true); + if (empty($engine)) { throw new SerpApiException('engine must be present'); } - $this->apiKey = $apiKey; - $this->engine = $engine; - $this->timeout = $timeout; + $this->apiKey = (string) $apiKey; + $this->engine = (string) $engine; + $this->timeout = (int) $timeout; + $this->persistent = (bool) $persistent; + $this->params = $params; + } + + /** + * Remove a key from an options array and return its value, or the + * default when the key is absent or null. + * + * @param array $options + * @param mixed $default + * @return mixed + */ + private static function pull(array &$options, string $key, $default) + { + $value = $options[$key] ?? $default; + unset($options[$key]); + + return $value; } /** @@ -65,6 +120,137 @@ public function getEngine(): string return $this->engine; } + /** + * Get the request timeout in seconds. + */ + public function getTimeout(): int + { + return $this->timeout; + } + + /** + * Get the default search parameters sent with every request. + * + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + /** + * Whether the client reuses one connection across requests. + */ + public function isPersistent(): bool + { + return $this->persistent; + } + + /** + * Close the shared connection. Safe to call more than once; the client + * stays usable and reconnects on the next request. + */ + public function close(): void + { + if ($this->hasOpenHandle()) { + self::closeHandle($this->handle); + } + + $this->handle = null; + } + + public function __destruct() + { + $this->close(); + } + + /** + * A clone opens its own connection rather than sharing, and later + * closing, the original's. + */ + public function __clone() + { + $this->handle = null; + } + + /** + * cURL handles cannot be serialized; the connection is reopened on the + * first request after unserializing. + * + * @return array + */ + public function __serialize(): array + { + $data = get_object_vars($this); + unset($data['handle']); + + return $data; + } + + /** + * @param array $data + */ + public function __unserialize(array $data): void + { + foreach ($data as $property => $value) { + $this->{$property} = $value; + } + } + + /** + * Human readable representation with the API key masked. + */ + public function inspect(): string + { + return sprintf( + '#<%s @engine=%s @timeout=%d @persistent=%s @api_key=%s>', + static::class, + $this->engine, + $this->timeout, + $this->persistent ? 'true' : 'false', + $this->maskedApiKey() + ); + } + + /** + * Keeps var_dump(), print_r() and debuggers from printing the API key in + * clear text. + * + * var_export() and (array) casts read properties directly and bypass this + * hook; use inspect() when dumping a client on purpose. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'engine' => $this->engine, + 'timeout' => $this->timeout, + 'persistent' => $this->persistent, + 'apiKey' => $this->maskedApiKey(), + 'params' => $this->params, + ]; + } + + /** + * Reveal only the first and last 4 characters of the API key, and none + * of a key too short for that to hide most of it. + */ + private function maskedApiKey(): string + { + $length = strlen($this->apiKey); + + if ($length === 0) { + return ''; + } + + if ($length <= 8) { + return '****'; + } + + return substr($this->apiKey, 0, 4) . '****' . substr($this->apiKey, -4); + } + /** * Run a search and return decoded JSON. * @@ -76,6 +262,18 @@ public function search(array $params = []): object return $this->get('/search', 'json', $params); } + /** + * Run a search and return decoded JSON as an associative array. + * + * @param array $params + * @return array + * @throws SerpApiException + */ + public function searchArray(array $params = []): array + { + return $this->get('/search', 'json', $params, true); + } + /** * Run a search and return Markdown optimized for LLMs and AI agents. * @@ -125,6 +323,7 @@ public function location(array $params = []): array * Retrieve search result from the Search Archive API. * * @return object|string + * @phpstan-return ($format is 'json' ? object : string) * @throws SerpApiException */ public function searchArchive(string $searchId, string $format = 'json') @@ -143,10 +342,11 @@ public function searchArchive(string $searchId, string $format = 'json') /** * @param array $params + * @param bool $assoc Decode JSON objects to associative arrays instead of stdClass * @return object|array|string * @throws SerpApiException */ - private function get(string $endpoint, string $format = 'json', array $params = []) + private function get(string $endpoint, string $format = 'json', array $params = [], bool $assoc = false) { if (!in_array($format, ['json', 'html', 'md'], true)) { throw new SerpApiException("Unsupported format '$format'. Expected 'html', 'json', or 'md'."); @@ -159,21 +359,17 @@ private function get(string $endpoint, string $format = 'json', array $params = throw new SerpApiException('api_key must be present'); } - $defaultQuery = [ - 'engine' => $this->engine, - 'source' => 'php', - ]; - - if (!empty($apiKey)) { - $defaultQuery['api_key'] = $apiKey; + if (array_key_exists('persistent', $params)) { + throw new SerpApiException('persistent can only be set on the client'); } - $query = array_merge($defaultQuery, $params); - $query['output'] = $format; + $timeout = (int) self::pull($params, 'timeout', $this->timeout); + + $query = $this->buildQuery($params, (string) $apiKey, $format); $url = self::BASE_URL . $endpoint . '?' . http_build_query($query); - $requestResult = $this->request($url); + $requestResult = $this->request($url, $timeout); $response = $requestResult['response']; $httpCode = $requestResult['http_code']; $curlError = $requestResult['curl_error']; @@ -190,15 +386,16 @@ private function get(string $endpoint, string $format = 'json', array $params = $this->raiseHttpError($httpCode, $endpoint, $query, null, null, $format); } - $decoded = json_decode($response); + $decoded = json_decode($response, $assoc); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { $this->raiseParserError($httpCode, $endpoint, $query); } - $serpApiError = (is_object($decoded) && isset($decoded->error)) ? $decoded->error : null; - $searchId = (is_object($decoded) && isset($decoded->search_metadata->id)) - ? (string) $decoded->search_metadata->id - : null; + $error = self::dig($decoded, 'error'); + $serpApiError = is_scalar($error) ? (string) $error : null; + + $id = self::dig(self::dig($decoded, 'search_metadata'), 'id'); + $searchId = is_scalar($id) ? (string) $id : null; if ($httpCode === 200) { if ($serpApiError !== null) { @@ -212,15 +409,65 @@ private function get(string $endpoint, string $format = 'json', array $params = } /** + * Read a key from decoded JSON, whether it was decoded to objects or + * associative arrays. + * + * @param mixed $data + * @return mixed + */ + private static function dig($data, string $key) + { + if (is_array($data)) { + return $data[$key] ?? null; + } + + if (is_object($data)) { + return $data->{$key} ?? null; + } + + return null; + } + + /** + * Assemble the query string parameters for a request. + * + * @param array $params + * @return array + */ + private function buildQuery(array $params, string $apiKey, string $format): array + { + $query = [ + 'engine' => $this->engine, + 'source' => self::SOURCE, + ]; + + if (!empty($apiKey)) { + $query['api_key'] = $apiKey; + } + + // A null value drops a default search parameter for a single call. + $overrides = array_filter(array_merge($this->params, $params), static function ($value) { + return $value !== null; + }); + + $query = array_merge($query, $overrides); + $query['output'] = $format; + + return $query; + } + + /** + * Perform the HTTP GET request. + * + * @internal Seam for tests to observe requests without network access; + * not covered by the backwards compatibility promise. + * * @return array{response: string|false, http_code: int, curl_error: string} * @throws SerpApiException */ - private function request(string $url): array + protected function request(string $url, int $timeout): array { - $ch = curl_init(); - if ($ch === false) { - throw new SerpApiException('Failed to initialize cURL handle'); - } + $ch = $this->acquireHandle(); try { $isConfigured = curl_setopt_array($ch, [ @@ -228,7 +475,7 @@ private function request(string $url): array CURLOPT_RETURNTRANSFER => true, CURLOPT_USERAGENT => 'serpapi-php/' . self::VERSION, CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_TIMEOUT => $timeout, ]); if ($isConfigured === false) { @@ -245,11 +492,56 @@ private function request(string $url): array 'curl_error' => $curlError, ]; } finally { - if (PHP_VERSION_ID < 80500) { - curl_close($ch); + if (!$this->persistent) { + self::closeHandle($ch); } + } + } + + /** + * The shared handle in persistent mode, otherwise a new one per request. + * + * @return resource|\CurlHandle + * @throws SerpApiException + */ + private function acquireHandle() + { + if ($this->persistent && $this->hasOpenHandle()) { + return $this->handle; + } - $ch = null; + $ch = curl_init(); + if ($ch === false) { + throw new SerpApiException('Failed to initialize cURL handle'); + } + + if ($this->persistent) { + $this->handle = $ch; + } + + return $ch; + } + + /** + * False for a closed PHP 7 resource too, and for the integer a resource + * turns into when serialized on PHP 7.2 and 7.3. + * + * @phpstan-assert-if-true !null $this->handle + */ + private function hasOpenHandle(): bool + { + return is_resource($this->handle) || $this->handle instanceof \CurlHandle; + } + + /** + * @param resource|\CurlHandle $handle + */ + private static function closeHandle($handle): void + { + // curl_close() is a no-op since PHP 8.0 and deprecated in 8.5; the + // handle is freed once nothing references it. + if (PHP_VERSION_ID < 80500) { + curl_close($handle); } } diff --git a/tests/ClientDefaultParamsTest.php b/tests/ClientDefaultParamsTest.php new file mode 100644 index 0000000..129c770 --- /dev/null +++ b/tests/ClientDefaultParamsTest.php @@ -0,0 +1,142 @@ + 'en', 'gl' => 'us']); + $client->search(['q' => 'coffee']); + $query = $client->lastQuery(); + + $this->assertEquals('en', $query['hl']); + $this->assertEquals('us', $query['gl']); + $this->assertEquals('coffee', $query['q']); + } + + public function testPerCallParamsOverrideDefaults() + { + $client = new RecordingClient('secret', 'google', 120, ['hl' => 'en']); + $client->search(['hl' => 'fr']); + + $this->assertEquals('fr', $client->lastQuery()['hl']); + } + + public function testNullDropsADefaultParamForOneCall() + { + $client = new RecordingClient('secret', 'google', 120, ['hl' => 'en']); + + $client->search(['hl' => null]); + $this->assertArrayNotHasKey('hl', $client->lastQuery()); + + $client->search(); + $this->assertEquals('en', $client->lastQuery()['hl']); + } + + public function testNullCannotDropTheEngineOrApiKey() + { + $client = new RecordingClient('secret', 'bing'); + $client->search(['engine' => null, 'api_key' => null]); + $query = $client->lastQuery(); + + $this->assertEquals('bing', $query['engine']); + $this->assertEquals('secret', $query['api_key']); + } + + public function testArrayConstructorSetsConfiguration() + { + $client = new Client([ + 'api_key' => 'secret', + 'engine' => 'bing', + 'timeout' => 30, + ]); + + $this->assertEquals('secret', $client->getApiKey()); + $this->assertEquals('bing', $client->getEngine()); + $this->assertEquals(30, $client->getTimeout()); + $this->assertEquals([], $client->getParams()); + } + + public function testArrayConstructorTreatsOtherKeysAsDefaultParams() + { + $client = new RecordingClient([ + 'api_key' => 'secret', + 'location' => 'Austin, TX', + 'no_cache' => true, + ]); + $client->search(['q' => 'coffee']); + $query = $client->lastQuery(); + + $this->assertEquals(['location' => 'Austin, TX', 'no_cache' => true], $client->getParams()); + $this->assertEquals('Austin, TX', $query['location']); + $this->assertEquals('1', $query['no_cache']); + } + + public function testArrayConstructorDefaultsEngineToGoogle() + { + $client = new Client(['api_key' => 'secret']); + $this->assertEquals('google', $client->getEngine()); + } + + public function testArrayConstructorRejectsEmptyEngine() + { + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('engine must be present'); + new Client(['api_key' => 'secret', 'engine' => '']); + } + + public function testConfigurationInParamsArgumentIsHonored() + { + $client = new Client('secret', 'google', 120, ['timeout' => 15, 'hl' => 'en']); + + $this->assertEquals(15, $client->getTimeout()); + $this->assertEquals(['hl' => 'en'], $client->getParams()); + } + + public function testPositionalConstructorRemainsSupported() + { + $client = new Client('secret', 'bing', 30); + + $this->assertEquals('secret', $client->getApiKey()); + $this->assertEquals('bing', $client->getEngine()); + $this->assertEquals(30, $client->getTimeout()); + } + + public function testMissingEnvironmentKeyIsTreatedAsEmpty() + { + // getenv() returns false for unset variables. + $client = new Client(false); + $this->assertEquals('', $client->getApiKey()); + } + + public function testClientTimeoutIsUsedByDefault() + { + $client = new RecordingClient('secret', 'google', 30); + $client->search(['q' => 'coffee']); + + $this->assertEquals([30], $client->timeouts); + } + + public function testPerCallTimeoutAppliesToThatRequestOnly() + { + $client = new RecordingClient('secret', 'google', 30); + $client->search(['q' => 'coffee', 'timeout' => 5]); + $this->assertArrayNotHasKey('timeout', $client->lastQuery()); + + $client->search(['q' => 'coffee']); + $this->assertEquals([5, 30], $client->timeouts); + } +} diff --git a/tests/ClientInspectTest.php b/tests/ClientInspectTest.php new file mode 100644 index 0000000..9c60cd1 --- /dev/null +++ b/tests/ClientInspectTest.php @@ -0,0 +1,69 @@ +assertStringNotContainsString(self::LONG_KEY, $client->inspect()); + } + + public function testInspectShowsFirstAndLastFourCharacters() + { + $client = new Client(self::LONG_KEY); + $this->assertStringContainsString('@api_key=abcd****wxyz', $client->inspect()); + } + + public function testInspectReportsEngineAndTimeout() + { + $inspect = (new Client(self::LONG_KEY, 'bing', 30))->inspect(); + + $this->assertStringContainsString('@engine=bing', $inspect); + $this->assertStringContainsString('@timeout=30', $inspect); + } + + public function testShortApiKeyIsFullyMasked() + { + $inspect = (new Client('abcdefgh'))->inspect(); + + $this->assertStringNotContainsString('abcd', $inspect); + $this->assertStringContainsString('@api_key=****', $inspect); + } + + public function testMissingApiKeyRendersAsEmpty() + { + $this->assertStringEndsWith('@api_key=>', (new Client())->inspect()); + } + + public function testVarDumpDoesNotExposeTheApiKey() + { + $client = new Client(['api_key' => self::LONG_KEY, 'hl' => 'en']); + + ob_start(); + var_dump($client); + $dump = (string) ob_get_clean(); + + $this->assertStringNotContainsString(self::LONG_KEY, $dump); + $this->assertStringContainsString('abcd****wxyz', $dump); + $this->assertStringContainsString('"hl"', $dump); + } + + public function testPrintRDoesNotExposeTheApiKey() + { + $dump = print_r(new Client(self::LONG_KEY), true); + + $this->assertStringNotContainsString(self::LONG_KEY, $dump); + $this->assertStringContainsString('abcd****wxyz', $dump); + } +} diff --git a/tests/ClientPersistentTest.php b/tests/ClientPersistentTest.php new file mode 100644 index 0000000..4132fed --- /dev/null +++ b/tests/ClientPersistentTest.php @@ -0,0 +1,146 @@ +setAccessible(true); + } + + return $property->getValue($client); + } + + public function testPersistentIsEnabledByDefault() + { + $this->assertTrue((new Client('secret'))->isPersistent()); + } + + public function testPersistentCanBeDisabled() + { + $this->assertFalse((new Client(['api_key' => 'secret', 'persistent' => false]))->isPersistent()); + $this->assertFalse((new Client('secret', 'google', 120, ['persistent' => false]))->isPersistent()); + } + + public function testPersistentIsNotSentAsASearchParameter() + { + $client = new RecordingClient(['api_key' => 'secret', 'persistent' => false]); + $client->search(['q' => 'coffee']); + + $this->assertArrayNotHasKey('persistent', $client->getParams()); + $this->assertArrayNotHasKey('persistent', $client->lastQuery()); + } + + public function testPerCallPersistentIsRejected() + { + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('persistent can only be set on the client'); + (new RecordingClient('secret'))->search(['persistent' => false]); + } + + public function testInspectReportsPersistentMode() + { + $this->assertStringContainsString('@persistent=true', (new Client('secret'))->inspect()); + $this->assertStringContainsString('@persistent=false', (new Client(['persistent' => false]))->inspect()); + } + + public function testRequestsWorkThroughTheSharedHandle() + { + $client = new LocalFileClient('secret'); + + $this->assertStringContainsString(self::MARKER, $client->fetch()); + $this->assertStringContainsString(self::MARKER, $client->fetch()); + } + + public function testNoConnectionIsOpenedBeforeTheFirstRequest() + { + $this->assertNull($this->handle(new LocalFileClient('secret'))); + } + + public function testPersistentClientReusesTheSameHandle() + { + $client = new LocalFileClient('secret'); + + $client->fetch(); + $first = $this->handle($client); + $client->fetch(); + + $this->assertNotNull($first); + $this->assertSame($first, $this->handle($client)); + } + + public function testNonPersistentClientKeepsNoHandle() + { + $client = new LocalFileClient(['api_key' => 'secret', 'persistent' => false]); + + $this->assertStringContainsString(self::MARKER, $client->fetch()); + $this->assertNull($this->handle($client)); + } + + public function testCloseIsIdempotentAndTheClientReconnects() + { + $client = new LocalFileClient('secret'); + $client->fetch(); + $first = $this->handle($client); + + $client->close(); + $client->close(); + $this->assertNull($this->handle($client)); + + $client->fetch(); + $this->assertNotNull($this->handle($client)); + $this->assertNotSame($first, $this->handle($client)); + } + + public function testCloneOpensItsOwnConnection() + { + $client = new LocalFileClient('secret'); + $client->fetch(); + $original = $this->handle($client); + + $copy = clone $client; + $this->assertNull($this->handle($copy)); + + $copy->fetch(); + unset($copy); + + $this->assertSame($original, $this->handle($client)); + $this->assertStringContainsString(self::MARKER, $client->fetch()); + } + + public function testClientCanBeSerializedAfterARequest() + { + $client = new LocalFileClient(['api_key' => 'secret', 'engine' => 'bing', 'hl' => 'en']); + $client->fetch(); + + $restored = unserialize(serialize($client)); + + $this->assertInstanceOf(LocalFileClient::class, $restored); + $this->assertEquals('bing', $restored->getEngine()); + $this->assertEquals('secret', $restored->getApiKey()); + $this->assertEquals(['hl' => 'en'], $restored->getParams()); + $this->assertStringContainsString(self::MARKER, $restored->fetch()); + } +} diff --git a/tests/ClientQueryTest.php b/tests/ClientQueryTest.php new file mode 100644 index 0000000..a54b49d --- /dev/null +++ b/tests/ClientQueryTest.php @@ -0,0 +1,64 @@ +search(['q' => 'coffee']); + + $this->assertEquals('serpapi-php:' . Client::VERSION, $client->lastQuery()['source']); + } + + public function testQueryIncludesEngineApiKeyAndOutput() + { + $client = new RecordingClient('secret', 'bing'); + $client->search(['q' => 'coffee']); + $query = $client->lastQuery(); + + $this->assertEquals('/search', $client->lastPath()); + $this->assertEquals('bing', $query['engine']); + $this->assertEquals('secret', $query['api_key']); + $this->assertEquals('coffee', $query['q']); + $this->assertEquals('json', $query['output']); + } + + public function testPerCallParamsOverrideEngineAndApiKey() + { + $client = new RecordingClient('secret'); + $client->search(['engine' => 'bing', 'api_key' => 'other']); + $query = $client->lastQuery(); + + $this->assertEquals('bing', $query['engine']); + $this->assertEquals('other', $query['api_key']); + } + + public function testLocationOmitsApiKeyWhenNotSet() + { + $client = (new RecordingClient())->willReturn('[]'); + $client->location(['q' => 'Austin']); + + $this->assertArrayNotHasKey('api_key', $client->lastQuery()); + } + + public function testOutputReflectsRequestedFormat() + { + $client = (new RecordingClient('secret'))->willReturn(''); + $client->html(['q' => 'coffee']); + + $this->assertEquals('html', $client->lastQuery()['output']); + } +} diff --git a/tests/ClientSearchArrayTest.php b/tests/ClientSearchArrayTest.php new file mode 100644 index 0000000..3baff57 --- /dev/null +++ b/tests/ClientSearchArrayTest.php @@ -0,0 +1,93 @@ +willReturn(self::RESULTS); + $results = $client->searchArray(['q' => 'coffee']); + + $this->assertIsArray($results); + $this->assertIsArray($results['organic_results'][0]); + $this->assertEquals('Coffee', $results['organic_results'][0]['title']); + $this->assertEquals('abc123', $results['search_metadata']['id']); + } + + public function testSearchArraySendsSameQueryAsSearch() + { + $client = (new RecordingClient('secret', 'bing'))->willReturn(self::RESULTS); + + $client->search(['q' => 'coffee']); + $objectQuery = $client->lastQuery(); + + $client->searchArray(['q' => 'coffee']); + + $this->assertEquals('/search', $client->lastPath()); + $this->assertEquals($objectQuery, $client->lastQuery()); + } + + public function testSearchStillReturnsObject() + { + $client = (new RecordingClient('secret'))->willReturn(self::RESULTS); + $results = $client->search(['q' => 'coffee']); + + $this->assertIsObject($results); + $this->assertEquals('Coffee', $results->organic_results[0]->title); + } + + public function testSearchArrayRaisesSerpApiError() + { + $client = (new RecordingClient('secret'))->willReturn( + '{"error":"Invalid API key.","search_metadata":{"id":"abc123"}}', + 401 + ); + + try { + $client->searchArray(['q' => 'coffee']); + $this->fail('Expected SerpApiException'); + } catch (SerpApiException $e) { + $this->assertEquals('Invalid API key.', $e->getSerpApiError()); + $this->assertEquals('abc123', $e->getSearchId()); + $this->assertEquals(401, $e->getResponseStatus()); + } + } + + public function testSearchArrayRaisesErrorReturnedWithStatus200() + { + $client = (new RecordingClient('secret'))->willReturn( + '{"error":"Google hasn\'t returned any results for this query."}' + ); + + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage("error: Google hasn't returned any results for this query."); + + $client->searchArray(['q' => 'zzzz']); + } + + public function testSearchArrayRaisesOnInvalidJson() + { + $client = (new RecordingClient('secret'))->willReturn('not json'); + + $this->expectException(SerpApiException::class); + $this->expectExceptionMessage('JSON parse error'); + + $client->searchArray(['q' => 'coffee']); + } +} diff --git a/tests/ExampleSearchAmazonTest.php b/tests/ExampleSearchAmazonTest.php new file mode 100644 index 0000000..3bd9d77 --- /dev/null +++ b/tests/ExampleSearchAmazonTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'amazon', + 'k' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `amazon` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleAiModeTest.php b/tests/ExampleSearchGoogleAiModeTest.php new file mode 100644 index 0000000..4d115fb --- /dev/null +++ b/tests/ExampleSearchGoogleAiModeTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_ai_mode', + 'q' => 'best coffee maker', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'reconstructed_markdown', + 'Error on `google_ai_mode` engine: no `reconstructed_markdown`' + ); + } +} diff --git a/tests/ExampleSearchGoogleAiOverviewTest.php b/tests/ExampleSearchGoogleAiOverviewTest.php new file mode 100644 index 0000000..277fde2 --- /dev/null +++ b/tests/ExampleSearchGoogleAiOverviewTest.php @@ -0,0 +1,29 @@ +serpApiClient('google'); + $search = $google->search(['q' => 'how does art work in android', 'no_cache' => true]); + + // Google shows most AI Overviews inline. Only some need the extra + // request this engine makes, so skip when none is returned. + $pageToken = $search->ai_overview->page_token ?? null; + if ($pageToken === null) { + $this->markTestSkipped('Google returned no AI Overview page_token for this query'); + } + + $client = $this->serpApiClient('google_ai_overview'); + $response = $client->search(['page_token' => $pageToken]); + $this->assertResponseHasProperty( + $response, + 'ai_overview', + 'Error on `google_ai_overview` engine: no `ai_overview`' + ); + } +} diff --git a/tests/ExampleSearchGoogleFinanceTest.php b/tests/ExampleSearchGoogleFinanceTest.php new file mode 100644 index 0000000..72bb74c --- /dev/null +++ b/tests/ExampleSearchGoogleFinanceTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_finance', + 'q' => 'GOOG:NASDAQ', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'summary', + 'Error on `google_finance` engine: no `summary`' + ); + } +} diff --git a/tests/ExampleSearchGoogleFlightsTest.php b/tests/ExampleSearchGoogleFlightsTest.php new file mode 100644 index 0000000..17d898e --- /dev/null +++ b/tests/ExampleSearchGoogleFlightsTest.php @@ -0,0 +1,35 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + + // Dates are relative to today so the example never asks for a past flight. + $this->searchParams = [ + 'engine' => 'google_flights', + 'departure_id' => 'LAX', + 'arrival_id' => 'AUS', + 'outbound_date' => date('Y-m-d', strtotime('+30 days')), + 'return_date' => date('Y-m-d', strtotime('+37 days')), + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + + // Google only ranks some routes, so either list counts as a result. + $this->assertNotNull( + $response->best_flights ?? $response->other_flights ?? null, + 'Error on `google_flights` engine: no `best_flights` or `other_flights`' + ); + } +} diff --git a/tests/ExampleSearchGoogleHotelsTest.php b/tests/ExampleSearchGoogleHotelsTest.php new file mode 100644 index 0000000..ef45bc0 --- /dev/null +++ b/tests/ExampleSearchGoogleHotelsTest.php @@ -0,0 +1,37 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + + // Dates are relative to today so the example never asks for a past stay. + $this->searchParams = [ + 'engine' => 'google_hotels', + 'q' => 'Bali Resorts', + 'check_in_date' => date('Y-m-d', strtotime('+30 days')), + 'check_out_date' => date('Y-m-d', strtotime('+31 days')), + 'adults' => '2', + 'currency' => 'USD', + 'gl' => 'us', + 'hl' => 'en', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'properties', + 'Error on `google_hotels` engine: no `properties`' + ); + } +} diff --git a/tests/ExampleSearchGoogleImagesLightTest.php b/tests/ExampleSearchGoogleImagesLightTest.php new file mode 100644 index 0000000..1c776dd --- /dev/null +++ b/tests/ExampleSearchGoogleImagesLightTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_images_light', + 'q' => 'Coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'images_results', + 'Error on `google_images_light` engine: no `images_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleImagesTest.php b/tests/ExampleSearchGoogleImagesTest.php new file mode 100644 index 0000000..ff12ece --- /dev/null +++ b/tests/ExampleSearchGoogleImagesTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_images', + 'q' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'images_results', + 'Error on `google_images` engine: no `images_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleImmersiveProductTest.php b/tests/ExampleSearchGoogleImmersiveProductTest.php new file mode 100644 index 0000000..4778bd0 --- /dev/null +++ b/tests/ExampleSearchGoogleImmersiveProductTest.php @@ -0,0 +1,34 @@ +serpApiClient('google_shopping'); + $search = $shopping->search(['q' => 'coffee maker']); + + $pageToken = null; + foreach ($search->shopping_results ?? [] as $product) { + if (isset($product->immersive_product_page_token)) { + $pageToken = $product->immersive_product_page_token; + break; + } + } + + if ($pageToken === null) { + $this->markTestSkipped('Google Shopping returned no immersive_product_page_token'); + } + + $client = $this->serpApiClient('google_immersive_product'); + $response = $client->search(['page_token' => $pageToken]); + $this->assertResponseHasProperty( + $response, + 'product_results', + 'Error on `google_immersive_product` engine: no `product_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleLightTest.php b/tests/ExampleSearchGoogleLightTest.php new file mode 100644 index 0000000..3381bee --- /dev/null +++ b/tests/ExampleSearchGoogleLightTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_light', + 'q' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `google_light` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleNewsLightTest.php b/tests/ExampleSearchGoogleNewsLightTest.php new file mode 100644 index 0000000..3bf3eb3 --- /dev/null +++ b/tests/ExampleSearchGoogleNewsLightTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_news_light', + 'q' => 'pizza', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'news_results', + 'Error on `google_news_light` engine: no `news_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleNewsTest.php b/tests/ExampleSearchGoogleNewsTest.php new file mode 100644 index 0000000..ce25565 --- /dev/null +++ b/tests/ExampleSearchGoogleNewsTest.php @@ -0,0 +1,31 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_news', + 'q' => 'pizza', + 'gl' => 'us', + 'hl' => 'en', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'news_results', + 'Error on `google_news` engine: no `news_results`' + ); + } +} diff --git a/tests/ExampleSearchGooglePatentsTest.php b/tests/ExampleSearchGooglePatentsTest.php new file mode 100644 index 0000000..6924d4f --- /dev/null +++ b/tests/ExampleSearchGooglePatentsTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_patents', + 'q' => '(Coffee)', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `google_patents` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleProductTest.php b/tests/ExampleSearchGoogleProductTest.php new file mode 100644 index 0000000..174a4e5 --- /dev/null +++ b/tests/ExampleSearchGoogleProductTest.php @@ -0,0 +1,34 @@ +serpApiClient('google_shopping'); + $search = $shopping->search(['q' => 'coffee maker']); + + $pageToken = null; + foreach ($search->shopping_results ?? [] as $product) { + if (isset($product->immersive_product_page_token)) { + $pageToken = $product->immersive_product_page_token; + break; + } + } + + if ($pageToken === null) { + $this->markTestSkipped('Google Shopping returned no immersive_product_page_token'); + } + + $client = $this->serpApiClient('google_product'); + $response = $client->search(['page_token' => $pageToken]); + $this->assertResponseHasProperty( + $response, + 'product_results', + 'Error on `google_product` engine: no `product_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleReverseImageTest.php b/tests/ExampleSearchGoogleReverseImageTest.php new file mode 100644 index 0000000..9b40d79 --- /dev/null +++ b/tests/ExampleSearchGoogleReverseImageTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_reverse_image', + 'image_url' => 'https://i.imgur.com/HBrB8p0.png', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'image_results', + 'Error on `google_reverse_image` engine: no `image_results`' + ); + } +} diff --git a/tests/ExampleSearchGoogleTrendsTest.php b/tests/ExampleSearchGoogleTrendsTest.php new file mode 100644 index 0000000..8b15a3b --- /dev/null +++ b/tests/ExampleSearchGoogleTrendsTest.php @@ -0,0 +1,30 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_trends', + 'q' => 'coffee,milk,bread,pasta,steak', + 'data_type' => 'TIMESERIES', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'interest_over_time', + 'Error on `google_trends` engine: no `interest_over_time`' + ); + } +} diff --git a/tests/ExampleSearchGoogleVideosTest.php b/tests/ExampleSearchGoogleVideosTest.php new file mode 100644 index 0000000..be86e9c --- /dev/null +++ b/tests/ExampleSearchGoogleVideosTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'google_videos', + 'q' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'video_results', + 'Error on `google_videos` engine: no `video_results`' + ); + } +} diff --git a/tests/ExampleSearchYandexTest.php b/tests/ExampleSearchYandexTest.php new file mode 100644 index 0000000..cfdadb7 --- /dev/null +++ b/tests/ExampleSearchYandexTest.php @@ -0,0 +1,29 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'yandex', + 'text' => 'coffee', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `yandex` engine: no `organic_results`' + ); + } +} diff --git a/tests/ExampleSearchYelpTest.php b/tests/ExampleSearchYelpTest.php new file mode 100644 index 0000000..4148762 --- /dev/null +++ b/tests/ExampleSearchYelpTest.php @@ -0,0 +1,30 @@ + */ + private $searchParams; + + protected function setUp(): void + { + parent::setUp(); + $this->searchParams = [ + 'engine' => 'yelp', + 'find_desc' => 'Coffee', + 'find_loc' => 'New York, NY, USA', + ]; + } + + public function testResultExists() + { + $client = $this->serpApiClient(); + $response = $client->search($this->searchParams); + $this->assertResponseHasProperty( + $response, + 'organic_results', + 'Error on `yelp` engine: no `organic_results`' + ); + } +} diff --git a/tests/LocalFileClient.php b/tests/LocalFileClient.php new file mode 100644 index 0000000..cd2eaf7 --- /dev/null +++ b/tests/LocalFileClient.php @@ -0,0 +1,17 @@ +request('file://' . __FILE__, 5)['response']; + } +} diff --git a/tests/RecordingClient.php b/tests/RecordingClient.php new file mode 100644 index 0000000..5c3bacb --- /dev/null +++ b/tests/RecordingClient.php @@ -0,0 +1,65 @@ + */ + public $urls = []; + + /** @var array */ + public $timeouts = []; + + /** @var string */ + private $body = '{"search_metadata":{"id":"abc123","status":"Success"}}'; + + /** @var int */ + private $httpCode = 200; + + public function willReturn(string $body, int $httpCode = 200): self + { + $this->body = $body; + $this->httpCode = $httpCode; + + return $this; + } + + protected function request(string $url, int $timeout): array + { + $this->urls[] = $url; + $this->timeouts[] = $timeout; + + return [ + 'response' => $this->body, + 'http_code' => $this->httpCode, + 'curl_error' => '', + ]; + } + + /** + * Query parameters of the most recent request. + * + * @return array + */ + public function lastQuery(): array + { + $url = (string) end($this->urls); + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + return $query; + } + + public function lastPath(): string + { + $url = (string) end($this->urls); + + return (string) parse_url($url, PHP_URL_PATH); + } +}