diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8dcc39a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,65 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Build & Test
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: |
+ 7.0.x
+ 8.0.x
+ 9.0.x
+ 10.0.x
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release
+ env:
+ API_KEY: ${{ secrets.API_KEY }}
+
+ - name: Run examples
+ env:
+ API_KEY: ${{ secrets.API_KEY }}
+ run: |
+ for proj in examples/*/; do
+ name=$(basename "$proj")
+ echo "::group::$name"
+ dotnet run --project "$proj" --configuration Release -- "$API_KEY"
+ echo "::endgroup::"
+ done
+
+ pack:
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Pack
+ run: dotnet pack serpapi/serpapi.csproj --configuration Release
+
+ - name: Upload package
+ uses: actions/upload-artifact@v7
+ with:
+ name: nuget-package
+ path: serpapi/bin/Release/*.nupkg
diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml
deleted file mode 100644
index cab59cf..0000000
--- a/.github/workflows/dotnet-core.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-name: serpapi-dotnet
-
-on: [push]
-
-jobs:
- build:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- dotnet: ['7.0.x', '8.0.x']
- #dotnet: ['6.0.x', '7.0.x', '8.0.x']
- name: test with Dotnet ${{ matrix.dotnet }} test
- steps:
- - uses: actions/checkout@v2
- - name: Setup dotnet
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: ${{ matrix.dotnet }}
- - name: test
- run: dotnet test
- env:
- API_KEY: ${{secrets.API_KEY}}
- DOTNET_VERSION: ${{matrix.dotnet}}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..d1b149d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,40 @@
+name: Release
+
+on:
+ push:
+ tags: ['v*']
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --configuration Release --no-restore
+
+ - name: Test
+ run: dotnet test --configuration Release --no-build
+ env:
+ API_KEY: ${{ secrets.API_KEY }}
+
+ - name: Pack
+ run: dotnet pack serpapi/serpapi.csproj --configuration Release --no-build
+
+ - name: Publish to NuGet
+ run: dotnet nuget push serpapi/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release create "${{ github.ref_name }}" --generate-notes
diff --git a/.gitignore b/.gitignore
index 529cbe2..cec29e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,18 @@
-serpapi/obj/
-test/obj/
-test/bin/
-serpapi/bin
+## .NET
+bin/
+obj/
+*.nupkg
+*.snupkg
+
+## IDE
+.vs/
.vscode/
+*.user
+*.suo
+
+## OS
+.DS_Store
+Thumbs.db
+
+## Build artifacts
newtonsoft.json/
-derive.rb
diff --git a/Directory.Build.props b/Directory.Build.props
index a2816f1..2366cbd 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,10 +1,8 @@
-
- $(TargetFramework);net7.0
-
- disable
- preview
- true
- true
-
+
+ true
+ true
+ false
+ LatestMajor
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index acfa340..7a7ad8c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,9 +3,15 @@
true
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/Makefile b/Makefile
deleted file mode 100644
index bad1be2..0000000
--- a/Makefile
+++ /dev/null
@@ -1,56 +0,0 @@
-
-# Automate SerpApi dotnet library
-# compilation, test and release
-#
-.PHONY: test
-
-name=serpapi
-root=`pwd`
-example=google
-
-all: clean restore build test
-
-# clean-up previous build
-clean:
- rm -rf test/obj test/bin
- rm -rf serpapi/obj serpapi/bin
- dotnet clean
-
-# rebuild local state
-restore:
- dotnet restore
-
-# build for all target framework defined in serpapi/serpapi.csproj
-build:
- dotnet build --configuration Release
-
-# run test regression
-test:
- dotnet test --configuration Release
-
-# run a simple application
-run:
- dotnet run
-
-# package the library
-pack:
- dotnet pack
-
-oobt: pack
- $(MAKE) run_oobt example=google
- $(MAKE) run_oobt example=bing
-
-run_oobt:
- cd example/${example} ; \
- dotnet add package --package-directory ${root}/${name} ${name} ; \
- dotnet build ; \
- dotnet run
-
-# Dotnet
-#
-# https://docs.microsoft.com/en-us/nuget/quickstart/create-and-publish-a-package-using-the-dotnet-cli
-#
-# Package API
-release: oobt
- open serpapi/bin/Debug
- open -a "Google\ Chrome" https://www.nuget.org/packages/manage/upload
diff --git a/README.md b/README.md
index c0e939a..b46a647 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,483 @@
+# SerpApi .NET Library
-dotnet is not available on arm64 debian 12
+[](https://www.nuget.org/packages/serpapi)
+[](https://github.com/serpapi/serpapi-dotnet/actions/workflows/ci.yml)
+
+Integrate search data into your .NET application, AI workflow, or LLM/RAG pipeline. This is the official .NET client for [SerpApi](https://serpapi.com).
+
+SerpApi supports Google, Google Maps, Google Shopping, Bing, Baidu, Yandex, Yahoo, DuckDuckGo, eBay, Walmart, YouTube, and [100+ engines](https://serpapi.com).
+
+## Features
+
+- Async-first with full `CancellationToken` support
+- Sync convenience wrappers
+- `IAsyncEnumerable` pagination
+- Dependency injection integration (`IHttpClientFactory`)
+- Targets .NET Standard 2.0, .NET 7, 8, 9, and 10
+- Zero external runtime dependencies
+
+## Installation
+
+```bash
+dotnet add package serpapi
+```
+
+## Simple Usage
+
+```csharp
+using SerpApi;
+
+using var client = new SerpApiClient(Environment.GetEnvironmentVariable("SERPAPI_KEY")!);
+
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_light",
+ ["q"] = "coffee"
+});
+
+foreach (var result in results.OrganicResults!.Value.EnumerateArray())
+{
+ Console.WriteLine(result.GetProperty("title").GetString());
+}
+```
+
+### Error handling
+
+```csharp
+try
+{
+ using var results = await client.SearchAsync(parameters);
+}
+catch (SerpApiKeyException) { /* 401 — invalid API key */ }
+catch (SerpApiHttpException ex) { /* 429, 500, etc — ex.StatusCode */ }
+catch (SerpApiTimeoutException) { /* request timed out */ }
+catch (SerpApiException ex) { /* catch-all */ }
+```
+
+## Search API usage
+
+### Get JSON results
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_light",
+ ["q"] = "coffee",
+ ["num"] = "10"
+});
+
+Console.WriteLine(results.SearchId);
+Console.WriteLine(results.OrganicResults);
+Console.WriteLine(results["local_results"]);
+```
+
+### Get HTML results
+
+```csharp
+string html = await client.HtmlAsync(new Dictionary
+{
+ ["engine"] = "google_light",
+ ["q"] = "coffee"
+});
+```
+
+### Pagination
+
+```csharp
+// Next page
+using var page2 = await client.NextPageAsync(results);
+
+// Iterate all pages as an async stream
+await foreach (var page in client.SearchPagesAsync(parameters, maxPages: 5))
+{
+ using (page)
+ {
+ Console.WriteLine($"Page has {page.OrganicResults?.GetArrayLength()} results");
+ }
+}
+```
+
+### Search concurrently
+
+```csharp
+var tasks = new[]
+{
+ client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google_light", ["q"] = "coffee"
+ }),
+ client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google_news_light", ["q"] = "coffee"
+ })
+};
+
+try
+{
+ var results = await Task.WhenAll(tasks);
+ // Process results here.
+}
+finally
+{
+ foreach (var task in tasks)
+ {
+ if (task.Status == TaskStatus.RanToCompletion)
+ task.Result.Dispose();
+ }
+}
+```
+
+### Location API
+
+```csharp
+var locations = await client.LocationAsync("Austin, TX", limit: 3);
+foreach (var loc in locations.EnumerateArray())
+{
+ Console.WriteLine(loc.GetProperty("name").GetString());
+}
+```
+
+### Search Archive API
+
+Retrieve a previous search (0 credits):
+
+```csharp
+using var archived = await client.SearchArchiveAsync("previous_search_id");
+```
+
+### Account API
+
+```csharp
+using var account = await client.AccountAsync();
+Console.WriteLine(account["plan_id"]);
+```
+
+## Basic examples per search engine
+
+### Search Google
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google",
+ ["q"] = "coffee",
+ ["location"] = "Austin, Texas"
+});
+```
+
+* see: https://serpapi.com/search-api
+
+### Search Google Light
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_light",
+ ["q"] = "coffee"
+});
+```
+
+* see: https://serpapi.com/google-light-api
+
+### Search Google Scholar
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_scholar",
+ ["q"] = "machine learning"
+});
+```
+
+* see: https://serpapi.com/google-scholar-api
+
+### Search Google News
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_news",
+ ["q"] = "artificial intelligence"
+});
+```
+
+* see: https://serpapi.com/google-news-api
+
+### Search Google Maps
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_maps",
+ ["q"] = "pizza",
+ ["ll"] = "@40.7455096,-74.0083012,14z"
+});
+```
+
+* see: https://serpapi.com/google-maps-api
+
+### Search Google Shopping
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_shopping",
+ ["q"] = "laptop"
+});
+```
+
+* see: https://serpapi.com/google-shopping-api
+
+### Search Google Jobs
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_jobs",
+ ["q"] = "software engineer"
+});
+```
+
+* see: https://serpapi.com/google-jobs-api
+
+### Search Google Images
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_images",
+ ["q"] = "sunset"
+});
+```
+
+* see: https://serpapi.com/images-results
+
+### Search Google Finance
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_finance",
+ ["q"] = "AAPL:NASDAQ"
+});
+```
+
+* see: https://serpapi.com/google-finance-api
+
+### Search Bing
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "bing",
+ ["q"] = "coffee"
+});
+```
+
+* see: https://serpapi.com/bing-search-api
+
+### Search DuckDuckGo
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "duckduckgo",
+ ["q"] = "coffee"
+});
+```
+
+* see: https://serpapi.com/duckduckgo-search-api
+
+### Search Baidu
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "baidu",
+ ["q"] = "coffee"
+});
+```
+
+* see: https://serpapi.com/baidu-search-api
+
+### Search Yahoo
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "yahoo",
+ ["p"] = "coffee"
+});
+```
+
+* see: https://serpapi.com/yahoo-search-api
+
+### Search YouTube
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "youtube",
+ ["search_query"] = "latte art"
+});
+```
+
+* see: https://serpapi.com/youtube-search-api
+
+### Search Walmart
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "walmart",
+ ["query"] = "coffee maker"
+});
+```
+
+* see: https://serpapi.com/walmart-search-api
+
+### Search eBay
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "ebay",
+ ["_nkw"] = "laptop"
+});
+```
+
+* see: https://serpapi.com/ebay-search-api
+
+### Search Amazon
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "amazon",
+ ["k"] = "coffee"
+});
+```
+
+* see: https://serpapi.com/amazon-search-api
+
+### Search Naver
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "naver",
+ ["query"] = "coffee"
+});
+```
+
+* see: https://serpapi.com/naver-search-api
+
+### Search Apple App Store
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "apple_app_store",
+ ["term"] = "coffee"
+});
+```
+
+* see: https://serpapi.com/apple-app-store
+
+### Search Home Depot
+
+```csharp
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "home_depot",
+ ["q"] = "drill"
+});
+```
+
+* see: https://serpapi.com/home-depot-search-api
+
+## Configuration
+
+```csharp
+using var client = new SerpApiClient("YOUR_API_KEY", new SerpApiClientOptions
+{
+ Timeout = TimeSpan.FromSeconds(30)
+});
+```
+
+### Dependency Injection
+
+```csharp
+builder.Services.AddSerpApi(options =>
+{
+ options.ApiKey = builder.Configuration["SerpApi:ApiKey"]!;
+ options.Timeout = TimeSpan.FromSeconds(30);
+});
+```
+
+Uses `IHttpClientFactory` for connection management.
+
+#### Resilience
+
+Install the `Microsoft.Extensions.Http.Resilience` package:
+
+```bash
+dotnet add package Microsoft.Extensions.Http.Resilience
+```
+
+```csharp
+builder.Services.AddSerpApi(options =>
+{
+ options.ApiKey = builder.Configuration["SerpApi:ApiKey"]!;
+})
+.AddStandardResilienceHandler();
+```
+
+#### Corporate proxy
+
+```csharp
+var handler = new HttpClientHandler
+{
+ Proxy = new WebProxy("http://proxy.corp.example:8080"),
+ UseProxy = true
+};
+using var client = new SerpApiClient(
+ new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "YOUR_API_KEY" });
+```
+
+## Examples
+
+See [`examples/`](examples/) for runnable projects:
+
+| Example | Use Case | Description |
+|---------|----------|-------------|
+| [LeadFinder](examples/LeadFinder/) | Lead generation | Find local businesses via Google Maps for sales outreach |
+| [CompetitorTracker](examples/CompetitorTracker/) | SEO & competitive intel | Monitor brand vs competitor SERP positions across engines |
+| [RankTracker](examples/RankTracker/) | SEO rank monitoring | Track keyword positions page-by-page with pagination |
+| [PriceMonitor](examples/PriceMonitor/) | Price monitoring | Compare product prices across Google Shopping and Walmart |
+| [AiResearchAgent](examples/AiResearchAgent/) | AI/RAG pipelines | Gather multi-source context (web + news + scholar) for LLMs |
+| [ContentDiscovery](examples/ContentDiscovery/) | Market research | Find trending topics, PAA questions, and content gaps |
+| [ErrorHandling](examples/ErrorHandling/) | Reliability | Exception types, retry patterns, and graceful degradation |
+| [DependencyInjection](examples/DependencyInjection/) | Enterprise integration | ASP.NET Core / generic host with `IHttpClientFactory` |
+
+```bash
+export SERPAPI_KEY=your_key_here
+cd examples/LeadFinder
+dotnet run
+```
+
+## Contributing
+
+Bug reports and pull requests are welcome on GitHub at https://github.com/serpapi/serpapi-dotnet.
+
+```bash
+git clone https://github.com/serpapi/serpapi-dotnet.git
+cd serpapi-dotnet
+dotnet build
+dotnet test
+```
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/examples/AiResearchAgent/AiResearchAgent.csproj b/examples/AiResearchAgent/AiResearchAgent.csproj
new file mode 100644
index 0000000..3e18b74
--- /dev/null
+++ b/examples/AiResearchAgent/AiResearchAgent.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/examples/AiResearchAgent/Program.cs b/examples/AiResearchAgent/Program.cs
new file mode 100644
index 0000000..397e0c6
--- /dev/null
+++ b/examples/AiResearchAgent/Program.cs
@@ -0,0 +1,127 @@
+// AI Research Agent: gather multi-source context for LLM/RAG pipelines.
+// Use case: Feed real-time search data into an AI assistant or RAG system.
+// Fans out across web, news, and scholar — returns structured context.
+
+using SerpApi;
+using System.Text.Json;
+
+var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY");
+if (string.IsNullOrEmpty(apiKey))
+{
+ Console.WriteLine("Usage: dotnet run -- ");
+ Console.WriteLine(" or: SERPAPI_KEY=... dotnet run");
+ return;
+}
+
+using var client = new SerpApiClient(apiKey);
+
+var topic = args.Length > 1 ? args[1] : "retrieval augmented generation";
+Console.WriteLine($"Research topic: \"{topic}\"\n");
+
+// Fan-out: web + news + academic papers in parallel (RAG context gathering)
+var web = client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_light",
+ ["q"] = topic,
+ ["num"] = "5"
+});
+
+var news = client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_news_light",
+ ["q"] = topic
+});
+
+var scholar = client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_scholar",
+ ["q"] = topic,
+ ["as_ylo"] = DateTime.UtcNow.Year.ToString()
+});
+
+var sources = new List<(string Type, string Title, string Url, string Snippet)>();
+
+try
+{
+ await Task.WhenAll(web, news, scholar);
+
+ // Collect web results
+ var webResult = await web;
+ if (webResult.OrganicResults is { } webItems)
+ {
+ foreach (var r in webItems.EnumerateArray().Take(5))
+ {
+ sources.Add((
+ "web",
+ r.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "",
+ r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "",
+ r.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : ""
+ ));
+ }
+ }
+
+ // Collect news
+ var newsResult = await news;
+ var newsItems = newsResult["news_results"];
+ if (newsItems is { } ni)
+ {
+ foreach (var r in ni.EnumerateArray().Take(3))
+ {
+ sources.Add((
+ "news",
+ r.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "",
+ r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "",
+ r.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : ""
+ ));
+ }
+ }
+
+ // Collect academic papers
+ var scholarResult = await scholar;
+ if (scholarResult.OrganicResults is { } papers)
+ {
+ foreach (var r in papers.EnumerateArray().Take(3))
+ {
+ sources.Add((
+ "scholar",
+ r.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "",
+ r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "",
+ r.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : ""
+ ));
+ }
+ }
+
+ // Output structured context (ready for LLM prompt injection)
+ Console.WriteLine($"Gathered {sources.Count} sources for RAG context:\n");
+
+ foreach (var group in sources.GroupBy(s => s.Type))
+ {
+ Console.WriteLine($"=== {group.Key.ToUpper()} ===");
+ foreach (var (type, title, url, snippet) in group)
+ {
+ Console.WriteLine($" • {title}");
+ if (!string.IsNullOrEmpty(snippet))
+ Console.WriteLine($" {snippet[..Math.Min(100, snippet.Length)]}...");
+ Console.WriteLine($" {url}\n");
+ }
+ }
+
+ // In a real RAG pipeline, you'd format these as context and pass to an LLM:
+ // var prompt = $"Based on these sources:\n{FormatSources(sources)}\n\nAnswer: {topic}";
+}
+catch (SerpApiException ex)
+{
+ Console.WriteLine($"Search error: {ex.Message}");
+}
+finally
+{
+ DisposeCompleted(web);
+ DisposeCompleted(news);
+ DisposeCompleted(scholar);
+}
+
+static void DisposeCompleted(Task task)
+{
+ if (task.Status == TaskStatus.RanToCompletion)
+ task.Result.Dispose();
+}
diff --git a/examples/CompetitorTracker/CompetitorTracker.csproj b/examples/CompetitorTracker/CompetitorTracker.csproj
new file mode 100644
index 0000000..3e18b74
--- /dev/null
+++ b/examples/CompetitorTracker/CompetitorTracker.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/examples/CompetitorTracker/Program.cs b/examples/CompetitorTracker/Program.cs
new file mode 100644
index 0000000..199130c
--- /dev/null
+++ b/examples/CompetitorTracker/Program.cs
@@ -0,0 +1,90 @@
+// Competitor Tracker: monitor your brand vs competitors across search engines.
+// Use case: Marketing teams tracking SERP visibility for key terms.
+// Runs searches across Google and Bing, reports who ranks where.
+
+using SerpApi;
+using System.Text.Json;
+
+var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY");
+if (string.IsNullOrEmpty(apiKey))
+{
+ Console.WriteLine("Usage: dotnet run -- ");
+ Console.WriteLine(" or: SERPAPI_KEY=... dotnet run");
+ return;
+}
+
+using var client = new SerpApiClient(apiKey);
+
+var keyword = "project management software";
+var trackedDomains = new[] { "asana.com", "monday.com", "clickup.com", "notion.so" };
+
+Console.WriteLine($"Tracking: \"{keyword}\"");
+Console.WriteLine($"Domains: {string.Join(", ", trackedDomains)}\n");
+
+// Search Google and Bing in parallel
+var google = client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_light",
+ ["q"] = keyword,
+ ["num"] = "20"
+});
+
+var bing = client.SearchAsync(new Dictionary
+{
+ ["engine"] = "bing",
+ ["q"] = keyword,
+ ["count"] = "20"
+});
+
+try
+{
+ await Task.WhenAll(google, bing);
+
+ var engines = new[] { ("Google", await google), ("Bing", await bing) };
+ foreach (var (engineName, response) in engines)
+ {
+ Console.WriteLine($"=== {engineName} Rankings ===");
+
+ var organic = response.OrganicResults;
+ if (organic is not { } results)
+ {
+ Console.WriteLine(" No results\n");
+ continue;
+ }
+
+ foreach (var domain in trackedDomains)
+ {
+ var position = -1;
+ var idx = 0;
+ foreach (var r in results.EnumerateArray())
+ {
+ idx++;
+ var link = r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "";
+ if (link.Contains(domain, StringComparison.OrdinalIgnoreCase))
+ {
+ position = idx;
+ break;
+ }
+ }
+
+ var status = position > 0 ? $"#{position}" : "Not in top 20";
+ Console.WriteLine($" {domain,-20} {status}");
+ }
+ Console.WriteLine();
+ }
+}
+catch (SerpApiException ex)
+{
+ Console.WriteLine($"Search error: {ex.Message}");
+}
+finally
+{
+ DisposeCompleted(google);
+ DisposeCompleted(bing);
+}
+
+static void DisposeCompleted(Task task)
+{
+ if (task.Status == TaskStatus.RanToCompletion)
+ task.Result.Dispose();
+}
diff --git a/examples/ContentDiscovery/ContentDiscovery.csproj b/examples/ContentDiscovery/ContentDiscovery.csproj
new file mode 100644
index 0000000..3e18b74
--- /dev/null
+++ b/examples/ContentDiscovery/ContentDiscovery.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/examples/ContentDiscovery/Program.cs b/examples/ContentDiscovery/Program.cs
new file mode 100644
index 0000000..f3914d6
--- /dev/null
+++ b/examples/ContentDiscovery/Program.cs
@@ -0,0 +1,88 @@
+// Content Discovery: find trending topics and content gaps for a niche.
+// Use case: Content marketers finding what to write about next.
+// Progressive refinement: trending news → related questions → competitor content.
+
+using SerpApi;
+using System.Text.Json;
+
+var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY");
+if (string.IsNullOrEmpty(apiKey))
+{
+ Console.WriteLine("Usage: dotnet run -- ");
+ Console.WriteLine(" or: SERPAPI_KEY=... dotnet run");
+ return;
+}
+
+using var client = new SerpApiClient(apiKey);
+
+var niche = "sustainable fashion";
+Console.WriteLine($"Content discovery for: \"{niche}\"\n");
+
+// Step 1: What's trending in news?
+Console.WriteLine("=== Trending News ===");
+using var newsResults = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_news_light",
+ ["q"] = niche
+});
+
+var newsItems = newsResults["news_results"];
+if (newsItems is { } news)
+{
+ foreach (var item in news.EnumerateArray().Take(5))
+ {
+ var title = item.TryGetProperty("title", out var t) ? t.GetString() : "";
+ var date = item.TryGetProperty("date", out var d) ? d.GetString() : "";
+ Console.WriteLine($" • {title} ({date})");
+ }
+}
+
+// Step 2: What questions do people ask?
+Console.WriteLine("\n=== People Also Ask ===");
+using var searchResults = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_light",
+ ["q"] = niche
+});
+
+var paa = searchResults["related_questions"];
+if (paa is { } questions)
+{
+ foreach (var q in questions.EnumerateArray().Take(5))
+ {
+ var question = q.TryGetProperty("question", out var qText) ? qText.GetString() : "";
+ Console.WriteLine($" • {question}");
+ }
+}
+
+// Step 3: What are competitors ranking for?
+Console.WriteLine("\n=== Top Organic Competitors ===");
+if (searchResults.OrganicResults is { } organic)
+{
+ var domains = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var r in organic.EnumerateArray())
+ {
+ var link = r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "";
+ if (Uri.TryCreate(link, UriKind.Absolute, out var uri))
+ {
+ if (domains.Add(uri.Host))
+ {
+ var title = r.TryGetProperty("title", out var t) ? t.GetString() : "";
+ Console.WriteLine($" • {uri.Host,-30} \"{title}\"");
+ }
+ }
+ if (domains.Count >= 5) break;
+ }
+}
+
+// Step 4: Related searches (content gap opportunities)
+Console.WriteLine("\n=== Content Gap Ideas (Related Searches) ===");
+var related = searchResults["related_searches"];
+if (related is { } relatedItems)
+{
+ foreach (var r in relatedItems.EnumerateArray().Take(8))
+ {
+ var query = r.TryGetProperty("query", out var q) ? q.GetString() : "";
+ Console.WriteLine($" • {query}");
+ }
+}
diff --git a/examples/DependencyInjection/DependencyInjection.csproj b/examples/DependencyInjection/DependencyInjection.csproj
new file mode 100644
index 0000000..bcf4e3c
--- /dev/null
+++ b/examples/DependencyInjection/DependencyInjection.csproj
@@ -0,0 +1,14 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
diff --git a/examples/DependencyInjection/Program.cs b/examples/DependencyInjection/Program.cs
new file mode 100644
index 0000000..8cf30c5
--- /dev/null
+++ b/examples/DependencyInjection/Program.cs
@@ -0,0 +1,35 @@
+using Microsoft.Extensions.DependencyInjection;
+using SerpApi;
+
+var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY");
+if (string.IsNullOrEmpty(apiKey))
+{
+ Console.WriteLine("Usage: dotnet run -- ");
+ return;
+}
+
+// Configure services (like ASP.NET Core's Program.cs)
+var services = new ServiceCollection();
+
+services.AddSerpApi(options =>
+{
+ options.ApiKey = apiKey;
+ options.Timeout = TimeSpan.FromSeconds(30);
+});
+
+// Simulates what ASP.NET Core does at startup
+var provider = services.BuildServiceProvider();
+
+// Resolve from DI (like constructor injection in a controller)
+var client = provider.GetRequiredService();
+
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google",
+ ["q"] = "dependency injection patterns"
+});
+
+Console.WriteLine($"Search ID: {results.SearchId}");
+Console.WriteLine($"Results: {results.OrganicResults?.GetArrayLength()}");
+
+// The client lifecycle is managed by DI — no manual dispose needed
diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props
new file mode 100644
index 0000000..6e5343f
--- /dev/null
+++ b/examples/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+
+ false
+
+
diff --git a/examples/ErrorHandling/ErrorHandling.csproj b/examples/ErrorHandling/ErrorHandling.csproj
new file mode 100644
index 0000000..3e18b74
--- /dev/null
+++ b/examples/ErrorHandling/ErrorHandling.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/examples/ErrorHandling/Program.cs b/examples/ErrorHandling/Program.cs
new file mode 100644
index 0000000..2873e6d
--- /dev/null
+++ b/examples/ErrorHandling/Program.cs
@@ -0,0 +1,78 @@
+using SerpApi;
+
+// Demonstrate all exception types
+
+// 1. Missing API key
+Console.WriteLine("=== SerpApiKeyException (empty key) ===");
+try
+{
+ var bad = new SerpApiClient("");
+}
+catch (SerpApiKeyException ex)
+{
+ Console.WriteLine($" Caught: {ex.Message}");
+}
+
+// 2. Invalid API key (requires network)
+var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY");
+if (string.IsNullOrEmpty(apiKey))
+{
+ Console.WriteLine("\nSkipping network tests (no API key).");
+ Console.WriteLine("Usage: dotnet run -- ");
+ return;
+}
+
+Console.WriteLine("\n=== SerpApiKeyException (invalid key) ===");
+using (var client = new SerpApiClient("invalid_key_12345"))
+{
+ try
+ {
+ using var results = await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ });
+ }
+ catch (SerpApiKeyException ex)
+ {
+ Console.WriteLine($" Caught: {ex.Message}");
+ }
+}
+
+// 3. Timeout
+Console.WriteLine("\n=== SerpApiTimeoutException ===");
+using (var client = new SerpApiClient(apiKey, new SerpApiClientOptions
+{
+ Timeout = TimeSpan.FromMilliseconds(1) // impossibly short
+}))
+{
+ try
+ {
+ using var results = await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "timeout test"
+ });
+ }
+ catch (SerpApiTimeoutException ex)
+ {
+ Console.WriteLine($" Caught: {ex.Message}");
+ }
+}
+
+// 4. Successful request with error handling
+Console.WriteLine("\n=== Successful search with error handling ===");
+using var safeClient = new SerpApiClient(apiKey);
+try
+{
+ using var results = await safeClient.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "error handling best practices"
+ });
+ Console.WriteLine($" Success! Got {results.OrganicResults?.GetArrayLength()} results");
+}
+catch (SerpApiException ex)
+{
+ Console.WriteLine($" Error: {ex.Message}");
+}
diff --git a/examples/LeadFinder/LeadFinder.csproj b/examples/LeadFinder/LeadFinder.csproj
new file mode 100644
index 0000000..3e18b74
--- /dev/null
+++ b/examples/LeadFinder/LeadFinder.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/examples/LeadFinder/Program.cs b/examples/LeadFinder/Program.cs
new file mode 100644
index 0000000..4af470f
--- /dev/null
+++ b/examples/LeadFinder/Program.cs
@@ -0,0 +1,51 @@
+// Lead Finder: discover local businesses for outreach using Google Maps.
+// Use case: Sales teams finding leads by location and category.
+// Runs 1 API call, extracts business names, ratings, and addresses.
+
+using SerpApi;
+using System.Text.Json;
+
+var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY");
+if (string.IsNullOrEmpty(apiKey))
+{
+ Console.WriteLine("Usage: dotnet run -- ");
+ Console.WriteLine(" or: SERPAPI_KEY=... dotnet run");
+ return;
+}
+
+using var client = new SerpApiClient(apiKey);
+
+// Find plumbers in Austin, TX via Google Maps
+Console.WriteLine("Finding leads: plumbers in Austin, TX\n");
+
+using var results = await client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_maps",
+ ["q"] = "plumbers",
+ ["ll"] = "@30.2672,-97.7431,14z",
+ ["type"] = "search"
+});
+
+var localResults = results["local_results"];
+if (localResults is not { } leads)
+{
+ Console.WriteLine("No results found.");
+ return;
+}
+
+Console.WriteLine($"Found {leads.GetArrayLength()} leads:\n");
+
+foreach (var lead in leads.EnumerateArray().Take(10))
+{
+ var name = lead.TryGetProperty("title", out var t) ? t.GetString() : "Unknown";
+ var rating = lead.TryGetProperty("rating", out var r) ? r.ToString() : "N/A";
+ var reviews = lead.TryGetProperty("reviews", out var rv) ? rv.ToString() : "0";
+ var address = lead.TryGetProperty("address", out var a) ? a.GetString() : "";
+ var phone = lead.TryGetProperty("phone", out var p) ? p.GetString() : "";
+
+ Console.WriteLine($" {name}");
+ Console.WriteLine($" Rating: {rating} ({reviews} reviews)");
+ if (!string.IsNullOrEmpty(address)) Console.WriteLine($" Address: {address}");
+ if (!string.IsNullOrEmpty(phone)) Console.WriteLine($" Phone: {phone}");
+ Console.WriteLine();
+}
diff --git a/examples/PriceMonitor/PriceMonitor.csproj b/examples/PriceMonitor/PriceMonitor.csproj
new file mode 100644
index 0000000..3e18b74
--- /dev/null
+++ b/examples/PriceMonitor/PriceMonitor.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/examples/PriceMonitor/Program.cs b/examples/PriceMonitor/Program.cs
new file mode 100644
index 0000000..b73cb03
--- /dev/null
+++ b/examples/PriceMonitor/Program.cs
@@ -0,0 +1,95 @@
+// Price Monitor: track product prices across shopping engines.
+// Use case: E-commerce teams monitoring competitor pricing in real time.
+// Runs concurrent searches on Google Shopping and Walmart.
+
+using SerpApi;
+using System.Text.Json;
+
+var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY");
+if (string.IsNullOrEmpty(apiKey))
+{
+ Console.WriteLine("Usage: dotnet run -- ");
+ Console.WriteLine(" or: SERPAPI_KEY=... dotnet run");
+ return;
+}
+
+using var client = new SerpApiClient(apiKey, new SerpApiClientOptions
+{
+ Timeout = TimeSpan.FromSeconds(30)
+});
+
+var product = "sony wh-1000xm5";
+Console.WriteLine($"Monitoring prices for: \"{product}\"\n");
+
+using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
+
+// Search Google Shopping and Walmart concurrently
+var googleShopping = client.SearchAsync(new Dictionary
+{
+ ["engine"] = "google_shopping",
+ ["q"] = product
+}, cts.Token);
+
+var walmart = client.SearchAsync(new Dictionary
+{
+ ["engine"] = "walmart",
+ ["query"] = product
+}, cts.Token);
+
+try
+{
+ await Task.WhenAll(googleShopping, walmart);
+
+ // Google Shopping results
+ Console.WriteLine("=== Google Shopping ===");
+ var gResults = await googleShopping;
+ var shopping = gResults["shopping_results"];
+ if (shopping is { } items)
+ {
+ foreach (var item in items.EnumerateArray().Take(5))
+ {
+ var title = item.TryGetProperty("title", out var t) ? t.GetString() : "?";
+ var price = item.TryGetProperty("extracted_price", out var p) ? $"${p}" : "N/A";
+ var source = item.TryGetProperty("source", out var s) ? s.GetString() : "";
+ Console.WriteLine($" {price,-8} {source,-15} {title}");
+ }
+ }
+ else Console.WriteLine(" No results");
+
+ // Walmart results
+ Console.WriteLine("\n=== Walmart ===");
+ var wResults = await walmart;
+ var organic = wResults["organic_results"];
+ if (organic is { } walmartItems)
+ {
+ foreach (var item in walmartItems.EnumerateArray().Take(5))
+ {
+ var title = item.TryGetProperty("title", out var t) ? t.GetString() : "?";
+ var price = item.TryGetProperty("primary_offer", out var po)
+ && po.TryGetProperty("offer_price", out var op)
+ ? $"${op}"
+ : "N/A";
+ Console.WriteLine($" {price,-8} {title}");
+ }
+ }
+ else Console.WriteLine(" No results");
+}
+catch (OperationCanceledException) when (cts.IsCancellationRequested)
+{
+ Console.WriteLine("Price search timed out after 20 seconds.");
+}
+catch (SerpApiException ex)
+{
+ Console.WriteLine($"Search error: {ex.Message}");
+}
+finally
+{
+ DisposeCompleted(googleShopping);
+ DisposeCompleted(walmart);
+}
+
+static void DisposeCompleted(Task task)
+{
+ if (task.Status == TaskStatus.RanToCompletion)
+ task.Result.Dispose();
+}
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..baca543
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,30 @@
+# Examples
+
+Real-world use case examples for the SerpApi .NET SDK.
+
+## Use Cases
+
+| Example | Use Case | Engines Used | API Calls |
+|---------|----------|-------------|-----------|
+| [LeadFinder](LeadFinder/) | Lead generation | Google Maps | 1 |
+| [CompetitorTracker](CompetitorTracker/) | SEO & competitive intel | Google Light, Bing | 2 (parallel) |
+| [RankTracker](RankTracker/) | SEO rank monitoring | Google Light | 1–3 (pagination) |
+| [PriceMonitor](PriceMonitor/) | Price monitoring | Google Shopping, Walmart | 2 (parallel) |
+| [AiResearchAgent](AiResearchAgent/) | AI/RAG context gathering | Google Light, Google News, Google Scholar | 3 (parallel) |
+| [ContentDiscovery](ContentDiscovery/) | Market research & content gaps | Google News, Google Light | 2 (sequential) |
+| [ErrorHandling](ErrorHandling/) | Reliability patterns | — | 0–2 |
+| [DependencyInjection](DependencyInjection/) | Enterprise DI integration | Google | 1 |
+
+## Running
+
+```bash
+export SERPAPI_KEY=your_key_here
+cd LeadFinder
+dotnet run
+```
+
+Or pass the key directly:
+
+```bash
+dotnet run -- your_key_here
+```
diff --git a/examples/RankTracker/Program.cs b/examples/RankTracker/Program.cs
new file mode 100644
index 0000000..b985a4a
--- /dev/null
+++ b/examples/RankTracker/Program.cs
@@ -0,0 +1,61 @@
+// Rank Tracker: monitor keyword positions page by page.
+// Use case: SEO teams tracking if a site appears in top 30 for target keywords.
+// Demonstrates pagination to scan multiple result pages.
+
+using SerpApi;
+using System.Text.Json;
+
+var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY");
+if (string.IsNullOrEmpty(apiKey))
+{
+ Console.WriteLine("Usage: dotnet run -- ");
+ Console.WriteLine(" or: SERPAPI_KEY=... dotnet run");
+ return;
+}
+
+using var client = new SerpApiClient(apiKey);
+
+var targetDomain = "serpapi.com";
+var keywords = new[] { "search api", "serp scraping", "google search api" };
+
+Console.WriteLine($"Tracking rankings for: {targetDomain}\n");
+
+foreach (var keyword in keywords)
+{
+ Console.Write($" \"{keyword}\" ... ");
+
+ var found = false;
+ var position = 0;
+
+ await foreach (var page in client.SearchPagesAsync(
+ new Dictionary
+ {
+ ["engine"] = "google_light",
+ ["q"] = keyword,
+ ["num"] = "10"
+ },
+ maxPages: 3))
+ {
+ using (page)
+ {
+ if (page.OrganicResults is not { } results) continue;
+
+ foreach (var result in results.EnumerateArray())
+ {
+ position++;
+ var link = result.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "";
+ if (link.Contains(targetDomain, StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine($"#{position}");
+ found = true;
+ break;
+ }
+ }
+ }
+
+ if (found) break;
+ }
+
+ if (!found)
+ Console.WriteLine($"Not in top {position}");
+}
diff --git a/examples/RankTracker/RankTracker.csproj b/examples/RankTracker/RankTracker.csproj
new file mode 100644
index 0000000..3e18b74
--- /dev/null
+++ b/examples/RankTracker/RankTracker.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/serpapi-dotnet.slnx b/serpapi-dotnet.slnx
new file mode 100644
index 0000000..6e75e3b
--- /dev/null
+++ b/serpapi-dotnet.slnx
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/serpapi.sln b/serpapi.sln
deleted file mode 100644
index 40c19fd..0000000
--- a/serpapi.sln
+++ /dev/null
@@ -1,48 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 15
-VisualStudioVersion = 15.0.26124.0
-MinimumVisualStudioVersion = 15.0.26124.0
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "serpapi", "serpapi\serpapi.csproj", "{B4066941-FAAF-40B3-BE82-C2573877403C}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "test", "test\test.csproj", "{BA5795C3-0649-4BB5-8884-841CF003B05D}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Debug|x64 = Debug|x64
- Debug|x86 = Debug|x86
- Release|Any CPU = Release|Any CPU
- Release|x64 = Release|x64
- Release|x86 = Release|x86
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|x64.ActiveCfg = Debug|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|x64.Build.0 = Debug|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|x86.ActiveCfg = Debug|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|x86.Build.0 = Debug|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|Any CPU.Build.0 = Release|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|x64.ActiveCfg = Release|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|x64.Build.0 = Release|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|x86.ActiveCfg = Release|Any CPU
- {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|x86.Build.0 = Release|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|x64.ActiveCfg = Debug|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|x64.Build.0 = Debug|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|x86.ActiveCfg = Debug|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|x86.Build.0 = Debug|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|Any CPU.Build.0 = Release|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|x64.ActiveCfg = Release|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|x64.Build.0 = Release|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|x86.ActiveCfg = Release|Any CPU
- {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|x86.Build.0 = Release|Any CPU
- EndGlobalSection
-EndGlobal
diff --git a/serpapi/SerpApiClient.cs b/serpapi/SerpApiClient.cs
new file mode 100644
index 0000000..05c86b8
--- /dev/null
+++ b/serpapi/SerpApiClient.cs
@@ -0,0 +1,426 @@
+using System.Net;
+using System.Text.Json;
+
+namespace SerpApi;
+
+///
+/// Official .NET client for SerpApi. Supports 100+ search engines.
+///
+///
+///
+/// using var client = new SerpApiClient("your_api_key");
+/// var result = await client.SearchAsync(new Dictionary<string, string>
+/// {
+/// ["engine"] = "google",
+/// ["q"] = "coffee"
+/// });
+///
+///
+public sealed class SerpApiClient : IDisposable
+{
+ private const string DefaultSource = "dotnet";
+
+ private readonly HttpClient _httpClient;
+ private readonly SerpApiClientOptions _options;
+ private readonly bool _ownsHttpClient;
+
+ ///
+ /// Creates a new SerpApi client with the specified API key.
+ ///
+ /// Your SerpApi API key from https://serpapi.com/manage-api-key
+ /// Optional configuration overrides.
+ public SerpApiClient(string apiKey, SerpApiClientOptions? options = null)
+ {
+ if (string.IsNullOrWhiteSpace(apiKey))
+ throw new SerpApiKeyException("API key must not be empty. Get one at https://serpapi.com/manage-api-key");
+
+ _options = new SerpApiClientOptions
+ {
+ ApiKey = apiKey,
+ BaseUrl = options?.BaseUrl ?? "https://serpapi.com",
+ Timeout = options?.Timeout ?? TimeSpan.FromSeconds(60)
+ };
+ _httpClient = new HttpClient { Timeout = _options.Timeout };
+ _ownsHttpClient = true;
+ }
+
+ ///
+ /// Creates a new SerpApi client using an externally managed HttpClient (for DI/IHttpClientFactory).
+ ///
+ /// Pre-configured HttpClient instance.
+ /// Configuration including API key.
+ public SerpApiClient(HttpClient httpClient, SerpApiClientOptions options)
+ {
+ if (options is null)
+ throw new ArgumentNullException(nameof(options));
+
+ if (string.IsNullOrWhiteSpace(options.ApiKey))
+ throw new SerpApiKeyException("API key must not be empty. Get one at https://serpapi.com/manage-api-key");
+
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _options = new SerpApiClientOptions
+ {
+ ApiKey = options.ApiKey,
+ BaseUrl = options.BaseUrl,
+ Timeout = options.Timeout
+ };
+ _ownsHttpClient = false;
+ }
+
+ ///
+ /// Execute a search and return parsed JSON results.
+ ///
+ /// Search parameters. Must include "engine" key.
+ /// Cancellation token.
+ /// Parsed response with convenience accessors.
+ public async Task SearchAsync(
+ Dictionary parameters,
+ CancellationToken cancellationToken = default)
+ {
+ if (parameters is null)
+ throw new ArgumentNullException(nameof(parameters));
+
+ var url = BuildUrl("/search", parameters, outputJson: true);
+ var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false);
+ var response = ParseResponse(json);
+ try
+ {
+ ThrowIfError(response);
+ return response;
+ }
+ catch
+ {
+ response.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Execute a search and return raw HTML.
+ ///
+ public async Task HtmlAsync(
+ Dictionary parameters,
+ CancellationToken cancellationToken = default)
+ {
+ if (parameters is null)
+ throw new ArgumentNullException(nameof(parameters));
+
+ var url = BuildUrl("/search", parameters, outputJson: false);
+ return await GetStringAsync(url, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Retrieve a previous search result from the archive (free, 0 credits).
+ ///
+ /// The search ID from SearchMetadata.
+ /// Cancellation token.
+ public async Task SearchArchiveAsync(
+ string searchId,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(searchId))
+ throw new ArgumentException("searchId must not be empty.", nameof(searchId));
+
+ var url = BuildUrl($"/searches/{Uri.EscapeDataString(searchId)}.json", new Dictionary(), outputJson: true);
+ var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false);
+ var response = ParseResponse(json);
+ try
+ {
+ ThrowIfError(response);
+ return response;
+ }
+ catch
+ {
+ response.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Get account information (0 credits).
+ ///
+ public async Task AccountAsync(CancellationToken cancellationToken = default)
+ {
+ var url = BuildUrl("/account", new Dictionary(), outputJson: true);
+ var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false);
+ var response = ParseResponse(json);
+ try
+ {
+ ThrowIfError(response);
+ return response;
+ }
+ catch
+ {
+ response.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Get supported locations matching a query.
+ ///
+ /// Location search query (e.g., "Austin, TX").
+ /// Max number of results.
+ /// Cancellation token.
+ public async Task LocationAsync(
+ string query,
+ int limit = 5,
+ CancellationToken cancellationToken = default)
+ {
+ var parameters = new Dictionary
+ {
+ ["q"] = query,
+ ["limit"] = limit.ToString()
+ };
+
+ var url = BuildUrl("/locations.json", parameters, outputJson: true, includeOutput: false);
+ var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+ catch (JsonException ex)
+ {
+ throw new SerpApiException($"Failed to parse location response: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Fetch the next page of results from a previous search response.
+ ///
+ /// A previous SerpApiResponse that contains pagination info.
+ /// Cancellation token.
+ /// The next page of results, or null if no next page exists.
+ public async Task NextPageAsync(
+ SerpApiResponse response,
+ CancellationToken cancellationToken = default)
+ {
+ if (response is null)
+ throw new ArgumentNullException(nameof(response));
+
+ var nextUrl = response.NextPageUrl;
+ if (string.IsNullOrEmpty(nextUrl))
+ return null;
+
+ return await FetchPageByUrlAsync(nextUrl!, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task FetchPageByUrlAsync(
+ string pageUrl,
+ CancellationToken cancellationToken)
+ {
+ // Validate URL origin to prevent SSRF / API key exfiltration
+ if (!Uri.TryCreate(pageUrl, UriKind.Absolute, out var parsedUri))
+ throw new SerpApiException($"Invalid pagination URL: {pageUrl}");
+
+ var expectedHost = new Uri(_options.BaseUrl).Host;
+ if (!string.Equals(parsedUri.Host, expectedHost, StringComparison.OrdinalIgnoreCase))
+ throw new SerpApiException($"Pagination URL host '{parsedUri.Host}' does not match expected '{expectedHost}'.");
+
+ if (parsedUri.Scheme != "https" && parsedUri.Scheme != "http")
+ throw new SerpApiException($"Pagination URL must use HTTP(S), got '{parsedUri.Scheme}'.");
+
+ var separator = pageUrl.Contains('?') ? "&" : "?";
+ var url = $"{pageUrl}{separator}api_key={Uri.EscapeDataString(_options.ApiKey!)}&source={DefaultSource}";
+ var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false);
+ var result = ParseResponse(json);
+ try
+ {
+ ThrowIfError(result);
+ return result;
+ }
+ catch
+ {
+ result.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Enumerate all pages of search results as an async stream.
+ /// Caller owns each yielded response and should dispose it.
+ ///
+ /// Search parameters.
+ /// Maximum number of pages to retrieve.
+ /// Cancellation token.
+ public IAsyncEnumerable SearchPagesAsync(
+ Dictionary parameters,
+ int maxPages = 100,
+ CancellationToken cancellationToken = default)
+ {
+ if (parameters is null)
+ throw new ArgumentNullException(nameof(parameters));
+
+ if (maxPages <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxPages), "maxPages must be at least 1.");
+
+ return SearchPagesIteratorAsync(parameters, maxPages, cancellationToken);
+ }
+
+ private async IAsyncEnumerable SearchPagesIteratorAsync(
+ Dictionary parameters,
+ int maxPages,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var current = await SearchAsync(parameters, cancellationToken).ConfigureAwait(false);
+ var nextUrl = current.NextPageUrl;
+ yield return current;
+
+ for (int i = 1; i < maxPages; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (string.IsNullOrEmpty(nextUrl))
+ yield break;
+
+ current = await FetchPageByUrlAsync(nextUrl!, cancellationToken).ConfigureAwait(false);
+ nextUrl = current.NextPageUrl;
+ yield return current;
+ }
+ }
+
+ // --- Synchronous convenience wrappers ---
+ // NOTE: Safe in console apps and ASP.NET Core. May deadlock in legacy
+ // frameworks with a SynchronizationContext (WinForms, WPF, ASP.NET 4.x).
+
+ ///
+ /// Execute a search synchronously. Prefer for non-blocking usage.
+ ///
+ public SerpApiResponse Search(Dictionary parameters)
+ => Task.Run(() => SearchAsync(parameters)).GetAwaiter().GetResult();
+
+ ///
+ /// Get HTML results synchronously.
+ ///
+ public string Html(Dictionary parameters)
+ => Task.Run(() => HtmlAsync(parameters)).GetAwaiter().GetResult();
+
+ ///
+ /// Get search archive synchronously.
+ ///
+ public SerpApiResponse SearchArchive(string searchId)
+ => Task.Run(() => SearchArchiveAsync(searchId)).GetAwaiter().GetResult();
+
+ ///
+ /// Get account info synchronously.
+ ///
+ public SerpApiResponse Account()
+ => Task.Run(() => AccountAsync()).GetAwaiter().GetResult();
+
+ ///
+ /// Get locations synchronously.
+ ///
+ public JsonElement Location(string query, int limit = 5)
+ => Task.Run(() => LocationAsync(query, limit)).GetAwaiter().GetResult();
+
+ // --- Private helpers ---
+
+ private string BuildUrl(
+ string endpoint,
+ Dictionary parameters,
+ bool outputJson,
+ bool includeOutput = true)
+ {
+ var queryParts = new List();
+
+ // Add API key
+ queryParts.Add($"api_key={Uri.EscapeDataString(_options.ApiKey!)}");
+
+ // Add user parameters
+ foreach (var kvp in parameters)
+ {
+ if (string.Equals(kvp.Key, "api_key", StringComparison.OrdinalIgnoreCase))
+ continue; // already added
+ queryParts.Add($"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}");
+ }
+
+ // Add output format
+ if (includeOutput)
+ queryParts.Add($"output={( outputJson ? "json" : "html" )}");
+
+ // Add source identifier
+ queryParts.Add($"source={DefaultSource}");
+
+ return $"{_options.BaseUrl.TrimEnd('/')}{endpoint}?{string.Join("&", queryParts)}";
+ }
+
+ private async Task GetStringAsync(string url, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
+
+ var content = await response.Content.ReadAsStringAsync(
+#if NET7_0_OR_GREATER
+ cancellationToken
+#endif
+ // netstandard2.0: ReadAsStringAsync has no CancellationToken overload
+ ).ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ string errorMessage = content;
+ try
+ {
+ using var doc = JsonDocument.Parse(content);
+ if (doc.RootElement.TryGetProperty("error", out var errorProp))
+ errorMessage = errorProp.GetString() ?? content;
+ }
+ catch { /* not JSON, use raw content */ }
+
+ if (errorMessage.IndexOf("Invalid API key", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ throw new SerpApiKeyException(errorMessage);
+ }
+
+ throw new SerpApiHttpException((int)response.StatusCode, errorMessage);
+ }
+
+ return content;
+ }
+ catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new SerpApiTimeoutException(
+ $"Request timed out after {_httpClient.Timeout.TotalSeconds}s", ex);
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new SerpApiException($"HTTP request failed: {ex.Message}", ex);
+ }
+ }
+
+ private static void ThrowIfError(SerpApiResponse response)
+ {
+ var error = response["error"];
+ if (error != null && error.Value.ValueKind == JsonValueKind.String)
+ {
+ var message = error.Value.GetString()!;
+ if (message.IndexOf("Invalid API key", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ throw new SerpApiKeyException(message);
+ }
+ throw new SerpApiException(message);
+ }
+ }
+
+ private static SerpApiResponse ParseResponse(string json)
+ {
+ try
+ {
+ return new SerpApiResponse(json);
+ }
+ catch (JsonException ex)
+ {
+ throw new SerpApiException($"Failed to parse response: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_ownsHttpClient)
+ _httpClient.Dispose();
+ }
+}
diff --git a/serpapi/SerpApiClientOptions.cs b/serpapi/SerpApiClientOptions.cs
new file mode 100644
index 0000000..fd9a680
--- /dev/null
+++ b/serpapi/SerpApiClientOptions.cs
@@ -0,0 +1,22 @@
+namespace SerpApi;
+
+///
+/// Configuration options for .
+///
+public sealed class SerpApiClientOptions
+{
+ ///
+ /// SerpApi API key. Obtain from https://serpapi.com/manage-api-key
+ ///
+ public string? ApiKey { get; set; }
+
+ ///
+ /// Base URL for the SerpApi service. Defaults to https://serpapi.com.
+ ///
+ public string BaseUrl { get; set; } = "https://serpapi.com";
+
+ ///
+ /// HTTP request timeout. Defaults to 60 seconds.
+ ///
+ public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60);
+}
diff --git a/serpapi/SerpApiExceptions.cs b/serpapi/SerpApiExceptions.cs
new file mode 100644
index 0000000..964360e
--- /dev/null
+++ b/serpapi/SerpApiExceptions.cs
@@ -0,0 +1,49 @@
+namespace SerpApi;
+
+///
+/// Base exception for all SerpApi errors.
+///
+public class SerpApiException : Exception
+{
+ ///
+ public SerpApiException(string message) : base(message) { }
+ ///
+ public SerpApiException(string message, Exception innerException) : base(message, innerException) { }
+}
+
+///
+/// Thrown when the API returns an HTTP error response.
+///
+public class SerpApiHttpException : SerpApiException
+{
+ ///
+ /// HTTP status code returned by the API.
+ ///
+ public int StatusCode { get; }
+
+ ///
+ public SerpApiHttpException(int statusCode, string message)
+ : base(message)
+ {
+ StatusCode = statusCode;
+ }
+}
+
+///
+/// Thrown when the API key is missing or invalid.
+///
+public class SerpApiKeyException : SerpApiException
+{
+ ///
+ public SerpApiKeyException(string message) : base(message) { }
+}
+
+///
+/// Thrown when an HTTP request times out.
+///
+public class SerpApiTimeoutException : SerpApiException
+{
+ ///
+ public SerpApiTimeoutException(string message, Exception innerException)
+ : base(message, innerException) { }
+}
diff --git a/serpapi/SerpApiResponse.cs b/serpapi/SerpApiResponse.cs
new file mode 100644
index 0000000..e133a9d
--- /dev/null
+++ b/serpapi/SerpApiResponse.cs
@@ -0,0 +1,113 @@
+using System.Text.Json;
+
+namespace SerpApi;
+
+///
+/// Wraps a SerpApi JSON response, providing dictionary-like access and convenience properties.
+///
+public sealed class SerpApiResponse : IDisposable
+{
+ private readonly JsonDocument _document;
+ private readonly JsonElement _root;
+ private readonly string _rawJson;
+
+ internal SerpApiResponse(string json)
+ {
+ _rawJson = json;
+ _document = JsonDocument.Parse(json);
+ _root = _document.RootElement;
+ }
+
+ ///
+ /// Access a top-level property by key. Returns null if the key does not exist.
+ ///
+ public JsonElement? this[string key]
+ {
+ get
+ {
+ if (_root.TryGetProperty(key, out var value))
+ return value;
+ return null;
+ }
+ }
+
+ ///
+ /// The search_metadata object from the response.
+ ///
+ public JsonElement? SearchMetadata => this["search_metadata"];
+
+ ///
+ /// The search_parameters object from the response.
+ ///
+ public JsonElement? SearchParameters => this["search_parameters"];
+
+ ///
+ /// The organic_results array from the response, if present.
+ ///
+ public JsonElement? OrganicResults => this["organic_results"];
+
+ ///
+ /// The search ID from search_metadata, used for archive lookups.
+ ///
+ public string? SearchId
+ {
+ get
+ {
+ var metadata = SearchMetadata;
+ if (metadata?.TryGetProperty("id", out var id) == true)
+ return id.GetString();
+ return null;
+ }
+ }
+
+ ///
+ /// Pagination info from serpapi_pagination, if present.
+ ///
+ public JsonElement? Pagination => this["serpapi_pagination"];
+
+ ///
+ /// URL for the next page of results, if available.
+ ///
+ public string? NextPageUrl
+ {
+ get
+ {
+ var pagination = Pagination;
+ if (pagination?.TryGetProperty("next", out var next) == true)
+ return next.GetString();
+ return null;
+ }
+ }
+
+ ///
+ /// Returns the raw JSON string of the response.
+ ///
+ public string RawJson => _rawJson;
+
+ ///
+ /// Deserialize the entire response to a specific type.
+ ///
+ public T? As() => JsonSerializer.Deserialize(_rawJson);
+
+ ///
+ /// Deserialize a specific property to a type.
+ ///
+ public T? GetProperty(string key)
+ {
+ var element = this[key];
+ if (element == null)
+ return default;
+ return JsonSerializer.Deserialize(element.Value.GetRawText());
+ }
+
+ ///
+ /// Get the root JSON element for advanced traversal.
+ ///
+ public JsonElement Root => _root;
+
+ ///
+ public override string ToString() => _rawJson;
+
+ ///
+ public void Dispose() => _document.Dispose();
+}
diff --git a/serpapi/SerpApiServiceCollectionExtensions.cs b/serpapi/SerpApiServiceCollectionExtensions.cs
new file mode 100644
index 0000000..3a480f8
--- /dev/null
+++ b/serpapi/SerpApiServiceCollectionExtensions.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace SerpApi;
+
+///
+/// Extension methods for registering SerpApiClient with Microsoft.Extensions.DependencyInjection.
+///
+public static class SerpApiServiceCollectionExtensions
+{
+ ///
+ /// Adds SerpApiClient to the service collection with IHttpClientFactory integration.
+ ///
+ /// The service collection.
+ /// Action to configure SerpApiClientOptions.
+ /// The IHttpClientBuilder for further configuration (e.g., Polly policies).
+ ///
+ ///
+ /// services.AddSerpApi(options =>
+ /// {
+ /// options.ApiKey = "your_api_key";
+ /// options.Timeout = TimeSpan.FromSeconds(30);
+ /// });
+ ///
+ ///
+ public static IHttpClientBuilder AddSerpApi(
+ this IServiceCollection services,
+ Action configure)
+ {
+ services.Configure(configure);
+
+ return services.AddHttpClient((sp, httpClient) =>
+ {
+ var options = sp.GetRequiredService>().Value;
+ httpClient.Timeout = options.Timeout;
+ })
+ .AddTypedClient((httpClient, sp) =>
+ {
+ var options = sp.GetRequiredService>().Value;
+ return new SerpApiClient(httpClient, options);
+ });
+ }
+}
diff --git a/serpapi/serpapi.cs b/serpapi/serpapi.cs
deleted file mode 100644
index 29ece92..0000000
--- a/serpapi/serpapi.cs
+++ /dev/null
@@ -1,225 +0,0 @@
-using System;
-using System.Net.Http;
-using Newtonsoft.Json.Linq;
-using System.Collections;
-using System.Threading.Tasks;
-using System.Text.RegularExpressions;
-
-/***
- * Client for SerpApi.com
- */
-namespace SerpApi
-{
- public class SerpApi
- {
- const string JSON_FORMAT = "json";
- const string HTML_FORMAT = "html";
- const string HOST = "https://serpapi.com";
-
- // contextual parameter provided to SerpApi
- public Hashtable defaultParameter;
-
- // Core HTTP search
- public HttpClient client;
-
- public SerpApi(Hashtable parameter = null)
- {
- // assign query parameter
- if(parameter == null) {
- parameter = new Hashtable();
- }
- this.defaultParameter = parameter;
-
- // initialize clean
- this.client = new HttpClient();
-
- // set default timeout to 60s
- this.setTimeoutSeconds(60);
- }
-
- /***
- * Set HTTP timeout in seconds
- */
- public void setTimeoutSeconds(int seconds)
- {
- this.client.Timeout = TimeSpan.FromSeconds(seconds);
- }
-
- /***
- * Get Json result
- */
- public JObject search(Hashtable parameter)
- {
- return json("/search", parameter);
- }
-
- /***
- * Get search archive for JSON results
- */
- public JObject searchArchive(string searchId)
- {
- return json("/searches/" + searchId + ".json", new Hashtable());
- }
-
- /***
- * Get search HTML results
- */
- public string html(Hashtable parameter)
- {
- return get("/search", parameter, false);
- }
-
- /***
- * Get user account
- */
- public JObject account(string apiKey = "")
- {
- Hashtable parameter = new Hashtable();
- if(apiKey != "") {
- parameter.Add("api_key", apiKey);
- }
- return json("/account", parameter);
- }
-
- /***
- * Get location using location API
- */
- public JArray location(Hashtable parameter)
- {
- // get json result
- string buffer = get("/locations.json", parameter, true);
- // parse json response (ignore http response status)
- try {
- JArray data = JArray.Parse(buffer);
- return data;
- }
- catch
- {
- // report error if something went wrong
- JObject data = JObject.Parse(buffer);
- if (data.ContainsKey("error"))
- {
- throw new ClientException(data.GetValue("error").ToString());
- }
- throw new ClientException("oops no error found when parsing: " + buffer);
- }
- }
-
- public string get(string endpoint, Hashtable parameter, bool jsonEnabled)
- {
- string url = createUrl(endpoint, parameter, jsonEnabled);
- // run asynchonous http query (.net framework implementation)
- Task queryTask = createQuery(url, jsonEnabled);
- // block until http query is completed
- queryTask.ConfigureAwait(true);
- // parse result into json
- return queryTask.Result;
- }
-
-
- public JObject json(string uri, Hashtable parameter)
- {
- // get json result
- string buffer = get(uri, parameter, true);
- // parse json response (ignore http response status)
- JObject data = JObject.Parse(buffer);
- // report error if something went wrong
- if (data.ContainsKey("error"))
- {
- throw new ClientException(data.GetValue("error").ToString());
- }
- return data;
- }
-
- // Convert parmaterContext into URL request.
- //
- // note:
- // - C# URL encoding is pretty buggy and the API provides method which are not functional.
- // - System.Web.HttpUtility.UrlEncode breaks if apply the full URL
- ///
- public string createUrl(string endpoint, Hashtable parameter, bool jsonEnabled)
- {
- // merge parameter
- Hashtable table = new Hashtable();
- // default parameter
- foreach(DictionaryEntry e in this.defaultParameter)
- {
- if (!parameter.ContainsKey(e.Key)) {
- table.Add(e.Key, e.Value);
- }
- }
- // user parameter override
- foreach(DictionaryEntry e in parameter)
- {
- table.Add(e.Key, e.Value);
- }
-
- string s = "";
- foreach (DictionaryEntry entry in table)
- {
- if (s != "")
- {
- s += "&";
- }
- // encode each value in case of special character
- s += entry.Key + "=" + System.Web.HttpUtility.UrlEncode((string)entry.Value, System.Text.Encoding.UTF8);
- }
-
- // append output format
- s += "&output=" + (jsonEnabled ? JSON_FORMAT : HTML_FORMAT);
-
- // append source language
- s += "&source=dotnet";
-
- return HOST + endpoint + "?" + s;
- }
-
- /***
- * Close socket connection associated to HTTP search
- */
- public void Close()
- {
- this.client.Dispose();
- }
-
- private async Task createQuery(string url, bool jsonEnabled)
- {
- // display url for debug:
- //Console.WriteLine("url: " + url);
- try
- {
- HttpResponseMessage response = await this.client.GetAsync(url);
- var content = await response.Content.ReadAsStringAsync();
- // return raw JSON
- if (jsonEnabled)
- {
- response.Dispose();
- return content;
- }
- // HTML response or other
- if (response.IsSuccessStatusCode)
- {
- response.Dispose();
- return content;
- }
- else
- {
- response.Dispose();
- throw new ClientException("Http request fail: " + content);
- }
- }
- catch (Exception ex)
- {
- // handle HTTP issues
- throw new ClientException(ex.ToString());
- }
- throw new ClientException("Oops something went very wrong");
- }
- }
-
- public class ClientException : Exception
- {
- public ClientException(string message) : base(message) { }
- }
-
-}
diff --git a/serpapi/serpapi.csproj b/serpapi/serpapi.csproj
index 91f79c6..7151bf0 100644
--- a/serpapi/serpapi.csproj
+++ b/serpapi/serpapi.csproj
@@ -1,34 +1,45 @@
- serpapi
+ netstandard2.0;net7.0;net8.0;net9.0;net10.0
+ SerpApi
serpapi
- 1.0.0
- 1.0.0
- victor@serpapi.com
- SerpApi.LLC
- true
- true
+ 2.0.0
+ SerpApi
+ SerpApi LLC
MIT
- README.md
- serpapi
- Integrate search data into your Ruby application. This library is the official wrapper for SerpApi. SerpApi supports Google, Google Maps, Google Shopping, Baidu, Yandex, Yahoo, eBay, App Stores, and more.
- Search,Engine,Google,Search,Result,JSON,Scraping,Scrape,Crawl,Baidu,Bing,eBay,Walmart,Yandex,YouTube,ML,Machine,learning
- SerpApi LLC 2019-2024
- false
- true
- true
+ SerpApi
+ Official .NET client for SerpApi — scrape and parse search engine results. Supports Google, Bing, Baidu, Yandex, Yahoo, DuckDuckGo, eBay, Walmart, YouTube, and 100+ engines.
+ SerpApi;Search;Google;Bing;SERP;Scraping;API;SEO;Baidu;Yandex;DuckDuckGo;YouTube;eBay;Walmart
+ SerpApi LLC 2019-2026
https://github.com/serpapi/serpapi-dotnet
git
- true
+ README.md
+ true
+ enable
+ enable
+ latest
+ true
+ true
+ true
+
+
+
true
-
+
+
+
-
+
+
+
+
+
+
-
+
diff --git a/test/DependencyInjectionTests.cs b/test/DependencyInjectionTests.cs
new file mode 100644
index 0000000..5f02bd9
--- /dev/null
+++ b/test/DependencyInjectionTests.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace SerpApi.Tests;
+
+public class DependencyInjectionTests
+{
+ [Fact]
+ public void AddSerpApi_RegistersClient()
+ {
+ var services = new ServiceCollection();
+ services.AddSerpApi(options =>
+ {
+ options.ApiKey = "test_key";
+ options.Timeout = TimeSpan.FromSeconds(30);
+ });
+
+ var provider = services.BuildServiceProvider();
+ var client = provider.GetRequiredService();
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public void AddSerpApi_ReturnsHttpClientBuilder()
+ {
+ var services = new ServiceCollection();
+ var builder = services.AddSerpApi(options =>
+ {
+ options.ApiKey = "test_key";
+ });
+
+ Assert.NotNull(builder);
+ }
+}
diff --git a/test/GlobalUsings.cs b/test/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/test/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/test/IntegrationTests.cs b/test/IntegrationTests.cs
new file mode 100644
index 0000000..02b841b
--- /dev/null
+++ b/test/IntegrationTests.cs
@@ -0,0 +1,462 @@
+using System.Text.Json;
+using Xunit;
+
+namespace SerpApi.Tests;
+
+///
+/// Integration tests that hit the real SerpApi.
+/// Skipped at runtime unless SERPAPI_API_KEY env var is set.
+/// Run with: dotnet test --filter "Category=Integration"
+///
+/// Uses async=true + no_cache=true: fires all searches concurrently at fixture init,
+/// polls results in parallel. Each test asserts on pre-fetched results (< 1ms).
+/// Slow tests (pagination, HTML) are in separate classes for xUnit parallelism.
+///
+public class IntegrationFixture : IAsyncLifetime
+{
+ public SerpApiClient? Client { get; private set; }
+ public Dictionary Results { get; } = new();
+
+ private static readonly Dictionary> Searches = new()
+ {
+ ["google"] = new() { ["engine"] = "google", ["q"] = "coffee", ["location"] = "Austin, Texas" },
+ ["google_archive"] = new() { ["engine"] = "google", ["q"] = "serpapi test archive" },
+ ["bing"] = new() { ["engine"] = "bing", ["q"] = "coffee" },
+ ["google_maps"] = new() { ["engine"] = "google_maps", ["q"] = "pizza", ["ll"] = "@40.7455096,-74.0083012,15.1z", ["type"] = "search" },
+ ["youtube"] = new() { ["engine"] = "youtube", ["search_query"] = "coffee" },
+ ["google_shopping"] = new() { ["engine"] = "google_shopping", ["q"] = "coffee maker" },
+ ["google_news"] = new() { ["engine"] = "google_news", ["q"] = "technology" },
+ ["google_scholar"] = new() { ["engine"] = "google_scholar", ["q"] = "machine learning" },
+ ["google_jobs"] = new() { ["engine"] = "google_jobs", ["q"] = "software engineer" },
+ ["duckduckgo"] = new() { ["engine"] = "duckduckgo", ["q"] = "coffee" },
+ ["yahoo"] = new() { ["engine"] = "yahoo", ["p"] = "coffee" },
+ ["baidu"] = new() { ["engine"] = "baidu", ["q"] = "coffee" },
+ ["yandex"] = new() { ["engine"] = "yandex", ["text"] = "coffee" },
+ ["apple_app_store"] = new() { ["engine"] = "apple_app_store", ["term"] = "weather" },
+ ["walmart"] = new() { ["engine"] = "walmart", ["query"] = "coffee" },
+ ["ebay"] = new() { ["engine"] = "ebay", ["_nkw"] = "coffee" },
+ ["naver"] = new() { ["engine"] = "naver", ["query"] = "coffee" },
+ ["google_images"] = new() { ["engine"] = "google_images", ["q"] = "coffee", ["tbm"] = "isch" },
+ ["google_events"] = new() { ["engine"] = "google_events", ["q"] = "coffee" },
+ ["google_autocomplete"] = new() { ["engine"] = "google_autocomplete", ["q"] = "coffee" },
+ ["google_local_services"] = new() { ["engine"] = "google_local_services", ["q"] = "electrician", ["data_cid"] = "6745062158417646970" },
+ ["google_reverse_image"] = new() { ["engine"] = "google_reverse_image", ["image_url"] = "https://i.imgur.com/HBrB8p0.png" },
+ ["google_play"] = new() { ["engine"] = "google_play", ["q"] = "kite", ["store"] = "apps" },
+ ["home_depot"] = new() { ["engine"] = "home_depot", ["q"] = "table" },
+ };
+
+ public async Task InitializeAsync()
+ {
+ var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY")
+ ?? Environment.GetEnvironmentVariable("API_KEY");
+ if (string.IsNullOrEmpty(apiKey)) return;
+
+ Client = new SerpApiClient(apiKey);
+
+ var searchIds = new Dictionary();
+ var fireTasks = Searches.Select(async kv =>
+ {
+ try
+ {
+ var parameters = new Dictionary(kv.Value)
+ {
+ ["async"] = "true",
+ ["no_cache"] = "true"
+ };
+ var queued = await Client.SearchAsync(parameters);
+ lock (searchIds) { searchIds[kv.Key] = queued.SearchId!; }
+ queued.Dispose();
+ }
+ catch
+ {
+ // Individual engine failure should not crash the fixture
+ }
+ });
+ await Task.WhenAll(fireTasks);
+
+ var pollTasks = searchIds.Select(async kv =>
+ {
+ try
+ {
+ for (var i = 0; i < 240; i++)
+ {
+ await Task.Delay(250);
+ var result = await Client.SearchArchiveAsync(kv.Value);
+ var status = result.SearchMetadata?.GetProperty("status").GetString();
+ if (status == "Success")
+ {
+ lock (Results) { Results[kv.Key] = result; }
+ return;
+ }
+ result.Dispose();
+ }
+ }
+ catch
+ {
+ // Individual engine poll failure should not crash the fixture
+ }
+ });
+ await Task.WhenAll(pollTasks);
+ }
+
+ public Task DisposeAsync()
+ {
+ foreach (var r in Results.Values) r.Dispose();
+ Client?.Dispose();
+ return Task.CompletedTask;
+ }
+}
+
+// --- Prefetched search tests (all < 1ms, share one fixture) ---
+
+[Trait("Category", "Integration")]
+public class SearchIntegrationTests : IClassFixture
+{
+ private readonly IntegrationFixture _f;
+ public SearchIntegrationTests(IntegrationFixture fixture) => _f = fixture;
+
+ private SerpApiResponse GetResult(string key)
+ {
+ Skip.If(_f.Client is null, "SERPAPI_API_KEY not set");
+ Skip.If(!_f.Results.ContainsKey(key), $"Engine '{key}' not available");
+ return _f.Results[key];
+ }
+
+ [SkippableFact]
+ public void Google_ReturnsOrganicResults()
+ {
+ var result = GetResult("google");
+
+ Assert.NotNull(result.SearchMetadata);
+ Assert.NotNull(result.SearchId);
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+
+ var first = result.OrganicResults!.Value[0];
+ Assert.True(first.TryGetProperty("title", out _));
+ Assert.True(first.TryGetProperty("link", out _));
+ }
+
+ [SkippableFact]
+ public void Bing_ReturnsOrganicResults()
+ {
+ var result = GetResult("bing");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleMaps_ReturnsLocalResults()
+ {
+ var result = GetResult("google_maps");
+ var localResults = result["local_results"];
+ Assert.NotNull(localResults);
+ Assert.True(localResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void YouTube_ReturnsVideoResults()
+ {
+ var result = GetResult("youtube");
+ var videoResults = result["video_results"];
+ Assert.NotNull(videoResults);
+ Assert.True(videoResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public async Task ArchiveRoundTrip_SearchThenRetrieve()
+ {
+ var original = GetResult("google_archive");
+ var searchId = original.SearchId;
+ Assert.NotNull(searchId);
+
+ var archived = await _f.Client!.SearchArchiveAsync(searchId!);
+ Assert.NotNull(archived.SearchId);
+ Assert.Equal(searchId, archived.SearchId);
+ }
+
+ [SkippableFact]
+ public void GoogleShopping_ReturnsShoppingResults()
+ {
+ var result = GetResult("google_shopping");
+ var shoppingResults = result["shopping_results"];
+ Assert.NotNull(shoppingResults);
+ Assert.True(shoppingResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleNews_ReturnsNewsResults()
+ {
+ var result = GetResult("google_news");
+ var newsResults = result["news_results"];
+ Assert.NotNull(newsResults);
+ Assert.True(newsResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleScholar_ReturnsOrganicResults()
+ {
+ var result = GetResult("google_scholar");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleJobs_ReturnsJobsResults()
+ {
+ var result = GetResult("google_jobs");
+ var jobsResults = result["jobs_results"];
+ Assert.NotNull(jobsResults);
+ Assert.True(jobsResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void DuckDuckGo_ReturnsOrganicResults()
+ {
+ var result = GetResult("duckduckgo");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void Yahoo_ReturnsOrganicResults()
+ {
+ var result = GetResult("yahoo");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void Baidu_ReturnsOrganicResults()
+ {
+ var result = GetResult("baidu");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void Yandex_ReturnsOrganicResults()
+ {
+ var result = GetResult("yandex");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void AppleAppStore_ReturnsOrganicResults()
+ {
+ var result = GetResult("apple_app_store");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void Walmart_ReturnsOrganicResults()
+ {
+ var result = GetResult("walmart");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void Ebay_ReturnsOrganicResults()
+ {
+ var result = GetResult("ebay");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void Naver_ReturnsResults()
+ {
+ var result = GetResult("naver");
+ var adsResults = result["ads_results"];
+ Assert.NotNull(adsResults);
+ Assert.True(adsResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleImages_ReturnsImageResults()
+ {
+ var result = GetResult("google_images");
+ var imageResults = result["images_results"];
+ Assert.NotNull(imageResults);
+ Assert.True(imageResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleEvents_ReturnsEventResults()
+ {
+ var result = GetResult("google_events");
+ var eventsResults = result["events_results"];
+ Assert.NotNull(eventsResults);
+ Assert.True(eventsResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleAutocomplete_ReturnsSuggestions()
+ {
+ var result = GetResult("google_autocomplete");
+ var suggestions = result["suggestions"];
+ Assert.NotNull(suggestions);
+ Assert.True(suggestions!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleLocalServices_ReturnsLocalAds()
+ {
+ var result = GetResult("google_local_services");
+ var localAds = result["local_ads"];
+ Assert.NotNull(localAds);
+ Assert.True(localAds!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void GoogleReverseImage_ReturnsImageResults()
+ {
+ var result = GetResult("google_reverse_image");
+ var imageResults = result["image_results"];
+ Assert.NotNull(imageResults);
+ }
+
+ [SkippableFact]
+ public void GooglePlay_ReturnsOrganicResults()
+ {
+ var result = GetResult("google_play");
+ Assert.NotNull(result.OrganicResults);
+ Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+
+ [SkippableFact]
+ public void HomeDepot_ReturnsProducts()
+ {
+ var result = GetResult("home_depot");
+ var products = result["products"];
+ Assert.NotNull(products);
+ Assert.True(products!.Value.GetArrayLength() > 0);
+ }
+}
+
+// --- Each slow test in its own class for parallel execution ---
+
+[Trait("Category", "Integration")]
+public class HtmlIntegrationTest
+{
+ [SkippableFact]
+ public async Task Google_HtmlEndpoint_ReturnsHtml()
+ {
+ var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY")
+ ?? Environment.GetEnvironmentVariable("API_KEY");
+ Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set");
+
+ using var client = new SerpApiClient(apiKey!);
+ var html = await client.HtmlAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "coffee",
+ ["no_cache"] = "true"
+ });
+
+ Assert.Contains("", html);
+ Assert.True(html.Length > 100);
+ }
+}
+
+[Trait("Category", "Integration")]
+public class PaginationNextPageTest
+{
+ [SkippableFact]
+ public async Task Pagination_NextPageWorks()
+ {
+ var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY")
+ ?? Environment.GetEnvironmentVariable("API_KEY");
+ Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set");
+
+ using var client = new SerpApiClient(apiKey!);
+ var firstPage = await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "coffee shops",
+ ["num"] = "10",
+ ["no_cache"] = "true"
+ });
+
+ Assert.NotNull(firstPage.NextPageUrl);
+
+ var secondPage = await client.NextPageAsync(firstPage);
+ Assert.NotNull(secondPage);
+ Assert.NotNull(secondPage!.OrganicResults);
+ Assert.True(secondPage.OrganicResults!.Value.GetArrayLength() > 0);
+ }
+}
+
+[Trait("Category", "Integration")]
+public class PaginationIteratorTest
+{
+ [SkippableFact]
+ public async Task SearchPagesAsync_IteratesMultiplePages()
+ {
+ var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY")
+ ?? Environment.GetEnvironmentVariable("API_KEY");
+ Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set");
+
+ using var client = new SerpApiClient(apiKey!);
+ var pages = new List();
+ await foreach (var page in client.SearchPagesAsync(
+ new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "best coffee beans",
+ ["num"] = "10",
+ ["no_cache"] = "true"
+ },
+ maxPages: 2))
+ {
+ pages.Add(page);
+ }
+
+ Assert.True(pages.Count >= 2, $"Expected at least 2 pages, got {pages.Count}");
+ foreach (var page in pages)
+ page.Dispose();
+ }
+}
+
+[Trait("Category", "Integration")]
+public class LocationIntegrationTest
+{
+ [SkippableFact]
+ public async Task Location_ReturnsResults()
+ {
+ var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY")
+ ?? Environment.GetEnvironmentVariable("API_KEY");
+ Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set");
+
+ using var client = new SerpApiClient(apiKey!);
+ var result = await client.LocationAsync("Austin, TX", limit: 3);
+
+ Assert.Equal(JsonValueKind.Array, result.ValueKind);
+ Assert.True(result.GetArrayLength() > 0);
+
+ var first = result[0];
+ Assert.True(first.TryGetProperty("name", out _));
+ Assert.True(first.TryGetProperty("google_id", out _));
+ }
+}
+
+[Trait("Category", "Integration")]
+public class AccountIntegrationTest
+{
+ [SkippableFact]
+ public async Task Account_ReturnsAccountInfo()
+ {
+ var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY")
+ ?? Environment.GetEnvironmentVariable("API_KEY");
+ Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set");
+
+ using var client = new SerpApiClient(apiKey!);
+ var result = await client.AccountAsync();
+
+ Assert.NotNull(result["account_id"]);
+ Assert.NotNull(result["api_key"]);
+ }
+}
diff --git a/test/MockHttpHandler.cs b/test/MockHttpHandler.cs
new file mode 100644
index 0000000..7abd9a7
--- /dev/null
+++ b/test/MockHttpHandler.cs
@@ -0,0 +1,23 @@
+using System.Net;
+
+namespace SerpApi.Tests;
+
+///
+/// Mock HTTP message handler for unit tests.
+///
+internal class MockHttpHandler : HttpMessageHandler
+{
+ private readonly Func> _handler;
+
+ public MockHttpHandler(Func> handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ return _handler(request, cancellationToken);
+ }
+}
diff --git a/test/SerpApiClientTests.cs b/test/SerpApiClientTests.cs
new file mode 100644
index 0000000..ae40c84
--- /dev/null
+++ b/test/SerpApiClientTests.cs
@@ -0,0 +1,975 @@
+using System.Net;
+using System.Text.Json;
+
+namespace SerpApi.Tests;
+
+public class SerpApiClientTests
+{
+ [Fact]
+ public void Constructor_ThrowsOnEmptyApiKey()
+ {
+ Assert.Throws(() => new SerpApiClient(""));
+ Assert.Throws(() => new SerpApiClient(" "));
+ }
+
+ [Fact]
+ public void Constructor_AcceptsValidApiKey()
+ {
+ using var client = new SerpApiClient("test_key_123");
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public void Constructor_WithOptions_SetsTimeout()
+ {
+ var options = new SerpApiClientOptions { Timeout = TimeSpan.FromSeconds(10) };
+ using var client = new SerpApiClient("key", options);
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public void Constructor_HttpClient_ThrowsOnEmptyApiKey()
+ {
+ var httpClient = new HttpClient();
+ var options = new SerpApiClientOptions { ApiKey = "" };
+ Assert.Throws(() => new SerpApiClient(httpClient, options));
+ }
+
+ [Fact]
+ public void Constructor_HttpClient_ThrowsOnNullOptions()
+ {
+ using var httpClient = new HttpClient();
+
+ var exception = Assert.Throws(
+ () => new SerpApiClient(httpClient, null!));
+
+ Assert.Equal("options", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task SearchAsync_BuildsCorrectUrl()
+ {
+ string? capturedUrl = null;
+ var handler = new MockHttpHandler((request, ct) =>
+ {
+ capturedUrl = request.RequestUri?.AbsoluteUri;
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"search_metadata\":{\"id\":\"abc\"},\"organic_results\":[]}")
+ });
+ });
+
+ var httpClient = new HttpClient(handler);
+ var options = new SerpApiClientOptions { ApiKey = "test_key" };
+ using var client = new SerpApiClient(httpClient, options);
+
+ await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "coffee",
+ ["location"] = "Austin, Texas"
+ });
+
+ Assert.NotNull(capturedUrl);
+ Assert.Contains("api_key=test_key", capturedUrl);
+ Assert.Contains("engine=google", capturedUrl);
+ Assert.Contains("q=coffee", capturedUrl);
+ Assert.Contains("location=Austin", capturedUrl);
+ Assert.Contains("output=json", capturedUrl);
+ Assert.Contains("source=dotnet", capturedUrl);
+ Assert.StartsWith("https://serpapi.com/search?", capturedUrl);
+ }
+
+ [Fact]
+ public async Task SearchAsync_ReturnsResponse()
+ {
+ var json = """
+ {
+ "search_metadata": {"id": "abc123", "status": "Success"},
+ "search_parameters": {"engine": "google", "q": "test"},
+ "organic_results": [
+ {"position": 1, "title": "Test Result", "link": "https://example.com"}
+ ]
+ }
+ """;
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var result = await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ });
+
+ Assert.Equal("abc123", result.SearchId);
+ Assert.NotNull(result.OrganicResults);
+ Assert.Equal(JsonValueKind.Array, result.OrganicResults!.Value.ValueKind);
+ Assert.Equal(1, result.OrganicResults!.Value.GetArrayLength());
+ }
+
+ [Fact]
+ public async Task SearchAsync_ThrowsOnApiError()
+ {
+ var json = """{"error": "Invalid API key. Your API key should be here: https://serpapi.com/manage-api-key"}""";
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "bad_key" });
+
+ await Assert.ThrowsAsync(() =>
+ client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ }));
+ }
+
+ [Fact]
+ public async Task SearchAsync_ThrowsOnHttpError()
+ {
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.TooManyRequests)
+ {
+ Content = new StringContent("""{"error": "Rate limit exceeded"}""")
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var ex = await Assert.ThrowsAsync(() =>
+ client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ }));
+
+ Assert.Equal(429, ex.StatusCode);
+ Assert.Contains("Rate limit", ex.Message);
+ }
+
+ [Fact]
+ public async Task SearchAsync_ThrowsKeyExceptionOnInvalidKeyHttpError()
+ {
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)
+ {
+ Content = new StringContent("""{"error": "Invalid API key"}""")
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "invalid_key" });
+
+ var exception = await Assert.ThrowsAsync(() =>
+ client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ }));
+
+ Assert.Equal("Invalid API key", exception.Message);
+ }
+
+ [Fact]
+ public async Task SearchAsync_ThrowsHttpExceptionOnQuotaHttpError()
+ {
+ const string message = "You have run out of searches. Upgrade your API key plan.";
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.TooManyRequests)
+ {
+ Content = new StringContent($"{{\"error\":\"{message}\"}}")
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var exception = await Assert.ThrowsAsync(() =>
+ client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ }));
+
+ Assert.Equal(429, exception.StatusCode);
+ Assert.Equal(message, exception.Message);
+ }
+
+ [Fact]
+ public async Task SearchAsync_ThrowsOnTimeout()
+ {
+ var handler = new MockHttpHandler(async (_, ct) =>
+ {
+ await Task.Delay(TimeSpan.FromSeconds(10), ct);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ });
+
+ var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(50) };
+ using var client = new SerpApiClient(httpClient,
+ new SerpApiClientOptions { ApiKey = "key", Timeout = TimeSpan.FromSeconds(30) });
+
+ var exception = await Assert.ThrowsAsync(() =>
+ client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ }));
+
+ Assert.Contains("0.05s", exception.Message);
+ }
+
+ [Fact]
+ public async Task HtmlAsync_ReturnsHtmlString()
+ {
+ var html = "Search results";
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ Assert.Contains("output=html", request.RequestUri!.ToString());
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(html)
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var result = await client.HtmlAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ });
+
+ Assert.Equal(html, result);
+ }
+
+ [Fact]
+ public async Task SearchArchiveAsync_BuildsCorrectUrl()
+ {
+ string? capturedUrl = null;
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ capturedUrl = request.RequestUri?.ToString();
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"abc123"}}""")
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ await client.SearchArchiveAsync("abc123");
+ Assert.Contains("/searches/abc123.json", capturedUrl!);
+ }
+
+ [Fact]
+ public async Task SearchArchiveAsync_ThrowsOnEmptyId()
+ {
+ using var client = new SerpApiClient("key");
+ await Assert.ThrowsAsync(() => client.SearchArchiveAsync(""));
+ }
+
+ [Fact]
+ public async Task AccountAsync_ReturnsAccountInfo()
+ {
+ var json = """{"account_id":"123","api_key":"key","plan_id":"free"}""";
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ Assert.Contains("/account", request.RequestUri!.ToString());
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var result = await client.AccountAsync();
+ Assert.Equal("123", result["account_id"]!.Value.GetString());
+ }
+
+ [Fact]
+ public async Task LocationAsync_ReturnsArray()
+ {
+ var json = """[{"id":"585069bdee19ad271e9bc072","name":"Austin, TX","google_id":200635}]""";
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ var url = request.RequestUri!.ToString();
+ Assert.Contains("/locations.json", url);
+ Assert.Contains("q=Austin", url);
+ Assert.Contains("limit=3", url);
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var result = await client.LocationAsync("Austin", limit: 3);
+ Assert.Equal(JsonValueKind.Array, result.ValueKind);
+ Assert.Equal(1, result.GetArrayLength());
+ }
+
+ [Fact]
+ public async Task NextPageAsync_ReturnsNullWhenNoPagination()
+ {
+ var json = """{"search_metadata":{"id":"abc"},"organic_results":[]}""";
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var firstPage = await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ });
+
+ var nextPage = await client.NextPageAsync(firstPage);
+ Assert.Null(nextPage);
+ }
+
+ [Fact]
+ public async Task NextPageAsync_ThrowsOnNullResponse()
+ {
+ using var client = new SerpApiClient("key");
+
+ var exception = await Assert.ThrowsAsync(() =>
+ client.NextPageAsync(null!));
+
+ Assert.Equal("response", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task NextPageAsync_FollowsPaginationUrl()
+ {
+ var json = """
+ {
+ "search_metadata":{"id":"abc"},
+ "organic_results":[],
+ "serpapi_pagination":{"next":"https://serpapi.com/search?q=test&start=10"}
+ }
+ """;
+ string? capturedUrl = null;
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ capturedUrl = request.RequestUri?.AbsoluteUri;
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"def"},"organic_results":[]}""")
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var firstPage = new SerpApiResponse(json);
+ var nextPage = await client.NextPageAsync(firstPage);
+
+ Assert.NotNull(nextPage);
+ Assert.NotNull(capturedUrl);
+ Assert.Contains("start=10", capturedUrl!);
+ Assert.Contains("api_key=key", capturedUrl!);
+ Assert.Contains("source=dotnet", capturedUrl!);
+ }
+
+ [Fact]
+ public async Task SearchAsync_SpecialCharactersEncodedCorrectly()
+ {
+ string? capturedUrl = null;
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ capturedUrl = request.RequestUri?.AbsoluteUri;
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"x"}}""")
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "café ñ 日本語"
+ });
+
+ // Verify the special characters are present in the URL (encoded)
+ Assert.NotNull(capturedUrl);
+ Assert.Contains("q=", capturedUrl!);
+ // The URL must not contain raw non-ASCII characters
+ Assert.DoesNotContain("café", capturedUrl!);
+ Assert.DoesNotContain("日本語", capturedUrl!);
+ }
+
+ [Fact]
+ public void Search_Sync_Works()
+ {
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"sync"},"organic_results":[]}""")
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var result = client.Search(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "sync test"
+ });
+
+ Assert.Equal("sync", result.SearchId);
+ }
+
+ [Fact]
+ public async Task SearchAsync_ApiKeyNotDuplicatedInParams()
+ {
+ string? capturedUrl = null;
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ capturedUrl = request.RequestUri?.ToString();
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"x"}}""")
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "main_key" });
+
+ await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test",
+ ["api_key"] = "override_key" // should be ignored
+ });
+
+ // api_key from constructor takes precedence, user-passed api_key is skipped
+ var apiKeyCount = capturedUrl!.Split("api_key=").Length - 1;
+ Assert.Equal(1, apiKeyCount);
+ Assert.Contains("api_key=main_key", capturedUrl);
+ }
+
+ [Fact]
+ public async Task SearchAsync_CustomBaseUrl()
+ {
+ string? capturedUrl = null;
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ capturedUrl = request.RequestUri?.ToString();
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"x"}}""")
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key", BaseUrl = "https://custom.api.com" });
+
+ await client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ });
+
+ Assert.StartsWith("https://custom.api.com/search?", capturedUrl!);
+ }
+
+ [Fact]
+ public async Task NextPageAsync_RejectsCrossOriginUrl()
+ {
+ var json = """
+ {
+ "search_metadata":{"id":"abc"},
+ "organic_results":[],
+ "serpapi_pagination":{"next":"https://attacker.com/steal?q=test"}
+ }
+ """;
+
+ using var client = new SerpApiClient(new HttpClient(new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"x"}}""")
+ }))),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var firstPage = new SerpApiResponse(json);
+ var ex = await Assert.ThrowsAsync(() => client.NextPageAsync(firstPage));
+ Assert.Contains("does not match", ex.Message);
+ }
+
+ [Fact]
+ public async Task AccountAsync_ThrowsOnApiError()
+ {
+ var json = """{"error": "Invalid API key. Your API key should be here: https://serpapi.com/manage-api-key"}""";
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "bad_key" });
+
+ await Assert.ThrowsAsync(() => client.AccountAsync());
+ }
+
+ [Fact]
+ public async Task SearchArchiveAsync_EscapesPathSegment()
+ {
+ string? capturedUrl = null;
+ var handler = new MockHttpHandler((request, _) =>
+ {
+ capturedUrl = request.RequestUri?.ToString();
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"x"}}""")
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ await client.SearchArchiveAsync("id/with/slashes");
+ Assert.DoesNotContain("/id/with/slashes", capturedUrl!);
+ Assert.Contains("id%2Fwith%2Fslashes", capturedUrl!);
+ }
+
+ [Fact]
+ public void SearchPagesAsync_ThrowsOnInvalidMaxPages()
+ {
+ using var client = new SerpApiClient("key");
+
+ Assert.Throws(() =>
+ client.SearchPagesAsync(
+ new Dictionary { ["engine"] = "google", ["q"] = "test" },
+ maxPages: 0));
+ }
+
+ [Fact]
+ public async Task SearchAsync_ThrowsOnNullParameters()
+ {
+ using var client = new SerpApiClient("key");
+
+ var exception = await Assert.ThrowsAsync(
+ () => client.SearchAsync(null!));
+
+ Assert.Equal("parameters", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task HtmlAsync_ThrowsOnNullParameters()
+ {
+ using var client = new SerpApiClient("key");
+
+ var exception = await Assert.ThrowsAsync(
+ () => client.HtmlAsync(null!));
+
+ Assert.Equal("parameters", exception.ParamName);
+ }
+
+ [Fact]
+ public void SearchPagesAsync_ThrowsOnNullParameters()
+ {
+ using var client = new SerpApiClient("key");
+
+ var exception = Assert.Throws(
+ () => client.SearchPagesAsync(null!));
+
+ Assert.Equal("parameters", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_HttpClient_CopiesOptions()
+ {
+ var options = new SerpApiClientOptions
+ {
+ ApiKey = "original_key",
+ BaseUrl = "https://serpapi.com",
+ Timeout = TimeSpan.FromSeconds(30)
+ };
+
+ using var client = new SerpApiClient(new HttpClient(), options);
+
+ // Mutating the original options after construction should not affect the client
+ options.ApiKey = "mutated_key";
+ options.BaseUrl = "https://evil.com";
+
+ // Client should still use original values (verified indirectly via construction succeeding)
+ Assert.NotNull(client);
+ }
+
+ // --- Cancellation ---
+
+ [Fact]
+ public async Task SearchAsync_RespectsCancellationToken()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var handler = new MockHttpHandler((_, ct) =>
+ {
+ ct.ThrowIfCancellationRequested();
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("""{"search_metadata":{"id":"x"}}""")
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ await Assert.ThrowsAnyAsync(() =>
+ client.SearchAsync(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ }, cts.Token));
+ }
+
+ [Fact]
+ public async Task SearchPagesAsync_RespectsCancellation()
+ {
+ var callCount = 0;
+ var handler = new MockHttpHandler((_, _) =>
+ {
+ callCount++;
+ var json = """{"search_metadata":{"id":"x"},"organic_results":[],"serpapi_pagination":{"next":"https://serpapi.com/search?start=10"}}""";
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ });
+ });
+
+ using var cts = new CancellationTokenSource();
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var pages = new List();
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await foreach (var page in client.SearchPagesAsync(
+ new Dictionary { ["engine"] = "google", ["q"] = "test" },
+ maxPages: 10,
+ cancellationToken: cts.Token))
+ {
+ pages.Add(page);
+ if (pages.Count == 2)
+ cts.Cancel();
+ }
+ });
+
+ Assert.True(pages.Count >= 2);
+ }
+
+ // --- SearchPagesAsync iteration ---
+
+ [Fact]
+ public async Task SearchPagesAsync_StopsWhenNoPagination()
+ {
+ var callCount = 0;
+ var handler = new MockHttpHandler((_, _) =>
+ {
+ callCount++;
+ var hasNext = callCount < 3;
+ var json = hasNext
+ ? """{"search_metadata":{"id":"x"},"organic_results":[],"serpapi_pagination":{"next":"https://serpapi.com/search?start=10"}}"""
+ : """{"search_metadata":{"id":"x"},"organic_results":[]}""";
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var pages = new List();
+ await foreach (var page in client.SearchPagesAsync(
+ new Dictionary { ["engine"] = "google", ["q"] = "test" },
+ maxPages: 10))
+ {
+ pages.Add(page);
+ }
+
+ Assert.Equal(3, pages.Count);
+ }
+
+ [Fact]
+ public async Task SearchPagesAsync_StopsAtMaxPages()
+ {
+ var handler = new MockHttpHandler((_, _) =>
+ {
+ var json = """{"search_metadata":{"id":"x"},"organic_results":[],"serpapi_pagination":{"next":"https://serpapi.com/search?start=10"}}""";
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json)
+ });
+ });
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var pages = new List();
+ await foreach (var page in client.SearchPagesAsync(
+ new Dictionary { ["engine"] = "google", ["q"] = "test" },
+ maxPages: 3))
+ {
+ pages.Add(page);
+ }
+
+ Assert.Equal(3, pages.Count);
+ }
+
+ // --- Sync wrappers ---
+
+ [Fact]
+ public void Html_SyncWorks()
+ {
+ var handler = new MockHttpHandler((_, _) =>
+ Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("results")
+ }));
+
+ using var client = new SerpApiClient(new HttpClient(handler),
+ new SerpApiClientOptions { ApiKey = "key" });
+
+ var result = client.Html(new Dictionary
+ {
+ ["engine"] = "google",
+ ["q"] = "test"
+ });
+
+ Assert.Contains("