From 584dc30d0ae7fb38dce95ce96defd85b09f13604 Mon Sep 17 00:00:00 2001 From: Magenta Date: Sat, 24 Oct 2026 21:55:25 +0200 Subject: [PATCH 1/4] refactor: migrate SerpApi requests to official Python SDK --- astrbot/core/tools/web_search_tools.py | 61 +++-- pyproject.toml | 1 + tests/unit/test_serpapi_search.py | 355 +++++++++++++------------ 3 files changed, 213 insertions(+), 204 deletions(-) diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index 35826648da2..bdb68c0f793 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -6,6 +6,7 @@ from urllib.parse import quote, urlsplit import aiohttp +import serpapi from pydantic import Field from pydantic.dataclasses import dataclass as pydantic_dataclass @@ -475,35 +476,41 @@ async def _serpapi_request( for _ in range(len(keys)): api_key = await _SERPAPI_KEY_ROTATOR.get(provider_settings) try: - async with aiohttp.ClientSession(trust_env=True) as session: - async with session.get( - "https://serpapi.com/search.json", - params={**payload, "engine": engine, "api_key": api_key}, - timeout=aiohttp.ClientTimeout(total=60), - ) as response: - if response.status == 200: - data = await response.json() - status = data.get("search_metadata", {}).get("status") - # SerpApi also returns an error message for successful - # searches with no results; these should remain empty. - if status == "Error" or ( - data.get("error") and status != "Success" - ): - reason = str(data.get("error") or "Unknown API error.") - raise RuntimeError( - f"SerpApi search failed: {reason.replace(api_key, '[redacted]')}" - ) - return data - reason = (await response.text()).replace(api_key, "[redacted]") - last_error = RuntimeError( - f"SerpApi search failed: {reason}, status: {response.status}" - ) - if response.status in {401, 403, 429}: - continue - raise last_error - except (aiohttp.ClientError, asyncio.TimeoutError): + client = serpapi.Client(api_key=api_key, timeout=60) + try: + data = await asyncio.to_thread( + client.search, + {**payload, "engine": engine}, + ) + finally: + client.session.close() + except serpapi.HTTPConnectionError: # Transport errors can include the request URL with its API key. raise RuntimeError("SerpApi search request failed or timed out.") from None + except serpapi.TimeoutError: + raise RuntimeError("SerpApi search request failed or timed out.") from None + except serpapi.HTTPError as exc: + reason = str(exc.error or exc).replace(api_key, "[redacted]") + last_error = RuntimeError( + f"SerpApi search failed: {reason}, status: {exc.status_code}" + ) + if exc.status_code in {401, 403, 429}: + continue + raise last_error from None + + if isinstance(data, serpapi.SerpResults): + data = data.as_dict() + elif not isinstance(data, dict): + raise RuntimeError("SerpApi search returned an unexpected response.") + status = data.get("search_metadata", {}).get("status") + # SerpApi also returns an error message for successful searches with no + # results; these should remain empty. + if status == "Error" or (data.get("error") and status != "Success"): + reason = str(data.get("error") or "Unknown API error.") + raise RuntimeError( + f"SerpApi search failed: {reason.replace(api_key, '[redacted]')}" + ) + return data if last_error is not None: raise last_error diff --git a/pyproject.toml b/pyproject.toml index e9f89f6fe1b..39a1c42f7f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ dependencies = [ "xlrd>=2.0.2", "opentelemetry-api>=1.45.1,<2", "opentelemetry-sdk>=1.45.1,<2", + "serpapi>=1.1.2", ] [dependency-groups] diff --git a/tests/unit/test_serpapi_search.py b/tests/unit/test_serpapi_search.py index 60cfefea760..2660cb29fe3 100644 --- a/tests/unit/test_serpapi_search.py +++ b/tests/unit/test_serpapi_search.py @@ -1,53 +1,50 @@ -import asyncio import json from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock -import aiohttp import pytest +import requests from astrbot.core.tools import registry from astrbot.core.tools import web_search_tools as tools -def _response(status=200, data=None, reason=""): - """Build a mock aiohttp response. +def _http_error(status: int, reason: str): + """Build a SerpApi SDK HTTP error. Args: status: HTTP status code. - data: JSON response body. reason: Text response body for HTTP errors. Returns: - An asynchronous response context manager. + An SDK exception carrying the status and API error message. """ - response = MagicMock(status=status) - response.__aenter__.return_value = response - response.json = AsyncMock(return_value=data or {}) - response.text = AsyncMock(return_value=reason) - return response + response = requests.Response() + response.status_code = status + response._content = json.dumps({"error": reason}).encode() + response.headers["Content-Type"] = "application/json" + return tools.serpapi.HTTPError(requests.HTTPError(reason, response=response)) @pytest.fixture def serpapi_http(monkeypatch): - """Mock the SerpApi transport and reset key rotation. + """Mock the SerpApi SDK client and reset key rotation. Args: monkeypatch: Pytest patch fixture. Returns: - The session and session factory mocks. + The client and client factory mocks. """ - session = MagicMock() - session.__aenter__.return_value = session - factory = MagicMock(return_value=session) - monkeypatch.setattr(tools.aiohttp, "ClientSession", factory) + client = MagicMock() + factory = MagicMock(return_value=client) + monkeypatch.setattr(tools.serpapi, "Client", factory) monkeypatch.setattr( tools, "_SERPAPI_KEY_ROTATOR", tools._KeyRotator("websearch_serpapi_key", "SerpApi"), ) - return SimpleNamespace(session=session, factory=factory) + return SimpleNamespace(client=client, factory=factory) @pytest.fixture @@ -96,7 +93,7 @@ async def test_serpapi_finance_preserves_quotes_fundamentals_and_sources( ], "graph": [{"price": 229.0, "date": "Oct 16 2026, 09:30 AM UTC-04:00"}], } - serpapi_http.session.get.return_value = _response(data=data) + serpapi_http.client.search.return_value = data result = json.loads( await tools.SerpApiFinanceSearchTool().call( @@ -104,9 +101,8 @@ async def test_serpapi_finance_preserves_quotes_fundamentals_and_sources( ) ) - assert serpapi_http.session.get.call_args.kwargs["params"] == { + assert serpapi_http.client.search.call_args.args[0] == { "engine": "google_finance", - "api_key": "test-key", "q": "AAPL:NASDAQ", "hl": "en", } @@ -122,13 +118,11 @@ async def test_serpapi_finance_preserves_quotes_fundamentals_and_sources( @pytest.mark.asyncio async def test_serpapi_finance_returns_requested_history(serpapi_http, serpapi_context): graph = [{"price": i, "currency": "USD", "date": f"Day {i}"} for i in range(120)] - serpapi_http.session.get.return_value = _response( - data={ - "summary": {"title": "Apple Inc", "price": "$230.00"}, - "graph": graph, - "key_events": [{"title": "Earnings", "date": "Day 100"}], - } - ) + serpapi_http.client.search.return_value = { + "summary": {"title": "Apple Inc", "price": "$230.00"}, + "graph": graph, + "key_events": [{"title": "Earnings", "date": "Day 100"}], + } result = json.loads( await tools.SerpApiFinanceSearchTool().call( @@ -136,7 +130,7 @@ async def test_serpapi_finance_returns_requested_history(serpapi_http, serpapi_c ) ) - assert serpapi_http.session.get.call_args.kwargs["params"]["window"] == "1Y" + assert serpapi_http.client.search.call_args.args[0]["window"] == "1Y" assert result["graph"] == graph assert result["key_events"] == [{"title": "Earnings", "date": "Day 100"}] assert ( @@ -155,7 +149,7 @@ async def test_serpapi_finance_preserves_suggestions_for_unknown_symbol( "link": "https://www.google.com/finance/quote/MSFT:NASDAQ", } ] - serpapi_http.session.get.return_value = _response(data={"suggestions": suggestions}) + serpapi_http.client.search.return_value = {"suggestions": suggestions} result = json.loads( await tools.SerpApiFinanceSearchTool().call(serpapi_context, query="MSFTR:") @@ -193,26 +187,24 @@ async def test_serpapi_scholar_preserves_publication_metadata_and_filters( "file_format": "PDF", } ] - serpapi_http.session.get.return_value = _response( - data={ - "organic_results": [ - { - "title": "A research paper", - "link": "https://example.com/paper", - "snippet": "Abstract excerpt", - "publication_info": publication_info, - "resources": resources, - "inline_links": { - "cited_by": { - "total": 42, - "link": "https://scholar.google.com/scholar?cites=123", - }, - "serpapi_cite_link": "https://serpapi.com/private-cite", + serpapi_http.client.search.return_value = { + "organic_results": [ + { + "title": "A research paper", + "link": "https://example.com/paper", + "snippet": "Abstract excerpt", + "publication_info": publication_info, + "resources": resources, + "inline_links": { + "cited_by": { + "total": 42, + "link": "https://scholar.google.com/scholar?cites=123", }, - } - ] - } - ) + "serpapi_cite_link": "https://serpapi.com/private-cite", + }, + } + ] + } result = json.loads( await tools.SerpApiScholarSearchTool().call( @@ -225,9 +217,8 @@ async def test_serpapi_scholar_preserves_publication_metadata_and_filters( ) )["results"] - assert serpapi_http.session.get.call_args.kwargs["params"] == { + assert serpapi_http.client.search.call_args.args[0] == { "engine": "google_scholar", - "api_key": "test-key", "q": "retrieval augmented generation", "num": 3, "hl": "en", @@ -252,9 +243,9 @@ async def test_serpapi_scholar_preserves_publication_metadata_and_filters( async def test_serpapi_scholar_limits_results_and_handles_citation_only_entries( serpapi_http, serpapi_context, limit, expected ): - serpapi_http.session.get.return_value = _response( - data={"organic_results": [{"title": f"Paper {i}"} for i in range(22)]} - ) + serpapi_http.client.search.return_value = { + "organic_results": [{"title": f"Paper {i}"} for i in range(22)] + } kwargs = {} if limit is None else {"max_results": limit} results = json.loads( @@ -264,7 +255,7 @@ async def test_serpapi_scholar_limits_results_and_handles_citation_only_entries( )["results"] assert len(results) == expected - assert serpapi_http.session.get.call_args.kwargs["params"]["num"] == expected + assert serpapi_http.client.search.call_args.args[0]["num"] == expected assert results[0]["url"] == "https://scholar.google.com/scholar?q=Paper%200" assert results[0]["cited_by_count"] is None @@ -273,16 +264,14 @@ async def test_serpapi_scholar_limits_results_and_handles_citation_only_entries( async def test_serpapi_scholar_uses_full_text_link_if_publisher_link_is_missing( serpapi_http, serpapi_context ): - serpapi_http.session.get.return_value = _response( - data={ - "organic_results": [ - { - "title": "Paper", - "resources": [{"link": "https://example.com/paper.pdf"}], - } - ] - } - ) + serpapi_http.client.search.return_value = { + "organic_results": [ + { + "title": "Paper", + "resources": [{"link": "https://example.com/paper.pdf"}], + } + ] + } result = json.loads( await tools.SerpApiScholarSearchTool().call( @@ -291,8 +280,8 @@ async def test_serpapi_scholar_uses_full_text_link_if_publisher_link_is_missing( )["results"] assert result[0]["url"] == "https://example.com/paper.pdf" - assert serpapi_http.session.get.call_args.kwargs["params"]["as_ylo"] == 2024 - assert "as_yhi" not in serpapi_http.session.get.call_args.kwargs["params"] + assert serpapi_http.client.search.call_args.args[0]["as_ylo"] == 2024 + assert "as_yhi" not in serpapi_http.client.search.call_args.args[0] @pytest.mark.asyncio @@ -323,10 +312,7 @@ async def test_serpapi_scholar_validates_query_and_years( async def test_serpapi_specialized_search_handles_empty_and_failed_responses( serpapi_http, serpapi_context, tool_class ): - serpapi_http.session.get.side_effect = [ - _response(), - _response(503, reason="API unavailable"), - ] + serpapi_http.client.search.side_effect = [{}, _http_error(503, "API unavailable")] result = json.loads(await tool_class().call(serpapi_context, query="example")) @@ -341,31 +327,29 @@ async def test_serpapi_images_light_maps_images_and_sources( ): cache = {} monkeypatch.setitem(tools.sp.temporary_cache, "_ws_favicon", cache) - serpapi_http.session.get.return_value = _response( - data={ - "images_results": [ - {"title": "Missing image"}, - { - "title": "AstrBot 标志", - "source": "Example", - "link": "https://example.com/article", - "original": "https://example.com/image.png", - "thumbnail": "https://example.com/thumb.png", - "original_width": 1200, - "original_height": 800, - "source_logo": "https://example.com/favicon.ico", - }, - { - "original": "x-raw-image:///pdf-image", - "thumbnail": "https://example.com/pdf-thumb.png", - "original_width": 500, - "original_height": 300, - }, - {"serpapi_thumbnail": "https://example.com/serpapi-thumb.png"}, - {"original": "data:image/png;base64,invalid"}, - ] - } - ) + serpapi_http.client.search.return_value = { + "images_results": [ + {"title": "Missing image"}, + { + "title": "AstrBot 标志", + "source": "Example", + "link": "https://example.com/article", + "original": "https://example.com/image.png", + "thumbnail": "https://example.com/thumb.png", + "original_width": 1200, + "original_height": 800, + "source_logo": "https://example.com/favicon.ico", + }, + { + "original": "x-raw-image:///pdf-image", + "thumbnail": "https://example.com/pdf-thumb.png", + "original_width": 500, + "original_height": 300, + }, + {"serpapi_thumbnail": "https://example.com/serpapi-thumb.png"}, + {"original": "data:image/png;base64,invalid"}, + ] + } result = json.loads( await tools.SerpApiImageSearchTool().call( @@ -373,9 +357,8 @@ async def test_serpapi_images_light_maps_images_and_sources( ) )["results"] - assert serpapi_http.session.get.call_args.kwargs["params"] == { + assert serpapi_http.client.search.call_args.args[0] == { "engine": "google_images_light", - "api_key": "test-key", "q": "AstrBot logo", "gl": "us", "hl": "en", @@ -406,13 +389,11 @@ async def test_serpapi_images_light_maps_images_and_sources( async def test_serpapi_images_light_limits_results( serpapi_http, serpapi_context, limit, expected ): - serpapi_http.session.get.return_value = _response( - data={ - "images_results": [ - {"original": f"https://example.com/{i}.png"} for i in range(12) - ] - } - ) + serpapi_http.client.search.return_value = { + "images_results": [ + {"original": f"https://example.com/{i}.png"} for i in range(12) + ] + } kwargs = {} if limit is None else {"max_results": limit} result = json.loads( @@ -422,9 +403,8 @@ async def test_serpapi_images_light_limits_results( ) assert len(result["results"]) == expected - assert serpapi_http.session.get.call_args.kwargs["params"] == { + assert serpapi_http.client.search.call_args.args[0] == { "engine": "google_images_light", - "api_key": "test-key", "q": "AstrBot", } @@ -433,12 +413,10 @@ async def test_serpapi_images_light_limits_results( async def test_serpapi_images_light_handles_empty_success( serpapi_http, serpapi_context ): - serpapi_http.session.get.return_value = _response( - data={ - "search_metadata": {"status": "Success"}, - "error": "No image results found.", - } - ) + serpapi_http.client.search.return_value = { + "search_metadata": {"status": "Success"}, + "error": "No image results found.", + } result = await tools.SerpApiImageSearchTool().call(serpapi_context, query="AstrBot") @@ -447,7 +425,7 @@ async def test_serpapi_images_light_handles_empty_success( @pytest.mark.asyncio async def test_serpapi_images_light_surfaces_api_errors(serpapi_http, serpapi_context): - serpapi_http.session.get.return_value = _response(503, reason="Search failed") + serpapi_http.client.search.side_effect = _http_error(503, "Search failed") with pytest.raises(RuntimeError, match="status: 503"): await tools.SerpApiImageSearchTool().call(serpapi_context, query="AstrBot") @@ -455,21 +433,19 @@ async def test_serpapi_images_light_surfaces_api_errors(serpapi_http, serpapi_co @pytest.mark.asyncio async def test_serpapi_tool_maps_query_results_and_citations(serpapi_http, monkeypatch): - serpapi_http.session.get.return_value = _response( - data={ - "search_metadata": {"status": "Success"}, - "organic_results": [ - {"title": "Missing link"}, - { - "title": "AstrBot 搜索", - "link": "https://example.com", - "snippet": "Search result", - "favicon": "https://example.com/favicon.ico", - }, - {"link": "https://example.org", "title": None, "snippet": None}, - ], - } - ) + serpapi_http.client.search.return_value = { + "search_metadata": {"status": "Success"}, + "organic_results": [ + {"title": "Missing link"}, + { + "title": "AstrBot 搜索", + "link": "https://example.com", + "snippet": "Search result", + "favicon": "https://example.com/favicon.ico", + }, + {"link": "https://example.org", "title": None, "snippet": None}, + ], + } cache = {} monkeypatch.setattr(tools.sp, "temporary_cache", {"_ws_favicon": cache}) config = {"provider_settings": {"websearch_serpapi_key": ["test-key"]}} @@ -492,18 +468,15 @@ async def test_serpapi_tool_maps_query_results_and_citations(serpapi_http, monke )["results"] get_config.assert_called_once_with(umo="test:private:session") - serpapi_http.factory.assert_called_once_with(trust_env=True) - call = serpapi_http.session.get.call_args - assert call.args == ("https://serpapi.com/search.json",) - assert call.kwargs["params"] == { + serpapi_http.factory.assert_called_once_with(api_key="test-key", timeout=60) + serpapi_http.client.session.close.assert_called_once_with() + assert serpapi_http.client.search.call_args.args[0] == { "engine": "google", - "api_key": "test-key", "q": "AstrBot 搜索", "gl": "cn", "hl": "zh-cn", "location": "Shanghai, China", } - assert call.kwargs["timeout"].total == 60 assert result[0] == { "title": "AstrBot 搜索", "url": "https://example.com", @@ -518,11 +491,9 @@ async def test_serpapi_tool_maps_query_results_and_citations(serpapi_http, monke @pytest.mark.asyncio @pytest.mark.parametrize("limit,expected", [(None, 5), (-2, 1), (3, 3), (100, 10)]) async def test_serpapi_tool_limits_first_page_results(serpapi_http, limit, expected): - serpapi_http.session.get.return_value = _response( - data={ - "organic_results": [{"link": f"https://example.com/{i}"} for i in range(12)] - } - ) + serpapi_http.client.search.return_value = { + "organic_results": [{"link": f"https://example.com/{i}"} for i in range(12)] + } config = {"provider_settings": {"websearch_serpapi_key": ["test-key"]}} context = SimpleNamespace( context=SimpleNamespace( @@ -535,9 +506,8 @@ async def test_serpapi_tool_limits_first_page_results(serpapi_http, limit, expec result = await tools.SerpApiWebSearchTool().call(context, query="AstrBot", **kwargs) assert len(json.loads(result)["results"]) == expected - assert serpapi_http.session.get.call_args.kwargs["params"] == { + assert serpapi_http.client.search.call_args.args[0] == { "engine": "google", - "api_key": "test-key", "q": "AstrBot", } @@ -548,10 +518,10 @@ async def test_serpapi_tool_limits_first_page_results(serpapi_http, limit, expec "engine", ["google", "google_images_light", "google_finance", "google_scholar"] ) async def test_serpapi_rotates_and_retries_key_errors(serpapi_http, status, engine): - serpapi_http.session.get.side_effect = [ - _response(status, reason="Key rejected"), - _response(), - _response(), + serpapi_http.client.search.side_effect = [ + _http_error(status, "Key rejected"), + {}, + {}, ] settings = {"websearch_serpapi_key": ["first-key", "second-key"]} payload = {"q": "AstrBot"} @@ -559,22 +529,23 @@ async def test_serpapi_rotates_and_retries_key_errors(serpapi_http, status, engi assert await tools._serpapi_request(settings, payload, engine=engine) == {} assert await tools._serpapi_request(settings, payload, engine=engine) == {} - assert [ - call.kwargs["params"]["api_key"] - for call in serpapi_http.session.get.call_args_list - ] == ["first-key", "second-key", "first-key"] + assert [call.kwargs["api_key"] for call in serpapi_http.factory.call_args_list] == [ + "first-key", + "second-key", + "first-key", + ] assert all( - call.kwargs["params"]["engine"] == engine - for call in serpapi_http.session.get.call_args_list + call.args[0]["engine"] == engine + for call in serpapi_http.client.search.call_args_list ) assert payload == {"q": "AstrBot"} @pytest.mark.asyncio async def test_serpapi_exhausts_keys_and_redacts_error(serpapi_http): - serpapi_http.session.get.side_effect = [ - _response(401, reason="Invalid first-key"), - _response(429, reason="Quota for second-key"), + serpapi_http.client.search.side_effect = [ + _http_error(401, "Invalid first-key"), + _http_error(429, "Quota for second-key"), ] with pytest.raises(RuntimeError, match="Quota for \\[redacted\\], status: 429"): @@ -582,28 +553,29 @@ async def test_serpapi_exhausts_keys_and_redacts_error(serpapi_http): {"websearch_serpapi_key": ["first-key", "second-key"]}, {"q": "AstrBot"} ) - assert serpapi_http.session.get.call_count == 2 + assert serpapi_http.client.search.call_count == 2 @pytest.mark.asyncio @pytest.mark.parametrize("status", [400, 500, 503]) async def test_serpapi_does_not_retry_other_http_errors(serpapi_http, status): - serpapi_http.session.get.return_value = _response(status, reason="Request failed") + serpapi_http.client.search.side_effect = _http_error(status, "Request failed") with pytest.raises(RuntimeError, match=f"status: {status}"): await tools._serpapi_search( {"websearch_serpapi_key": ["first-key", "second-key"]}, {"q": "AstrBot"} ) - assert serpapi_http.session.get.call_count == 1 + assert serpapi_http.client.search.call_count == 1 @pytest.mark.asyncio @pytest.mark.parametrize("metadata", [{}, {"status": "Error"}]) async def test_serpapi_surfaces_json_errors(serpapi_http, metadata): - serpapi_http.session.get.return_value = _response( - data={"search_metadata": metadata, "error": "Failed for test-key"} - ) + serpapi_http.client.search.return_value = { + "search_metadata": metadata, + "error": "Failed for test-key", + } with pytest.raises(RuntimeError, match="Failed for \\[redacted\\]"): await tools._serpapi_search( @@ -613,13 +585,11 @@ async def test_serpapi_surfaces_json_errors(serpapi_http, metadata): @pytest.mark.asyncio async def test_serpapi_success_with_empty_results_is_not_an_error(serpapi_http): - serpapi_http.session.get.return_value = _response( - data={ - "search_metadata": {"status": "Success"}, - "search_information": {"organic_results_state": "Fully empty"}, - "error": "Google hasn't returned any results for this query.", - } - ) + serpapi_http.client.search.return_value = { + "search_metadata": {"status": "Success"}, + "search_information": {"organic_results_state": "Fully empty"}, + "error": "Google hasn't returned any results for this query.", + } assert ( await tools._serpapi_search( @@ -630,21 +600,52 @@ async def test_serpapi_success_with_empty_results_is_not_an_error(serpapi_http): @pytest.mark.asyncio -@pytest.mark.parametrize("error_type", [aiohttp.ClientError, asyncio.TimeoutError]) +async def test_serpapi_accepts_sdk_result_objects(serpapi_http): + data = { + "search_metadata": {"status": "Success"}, + "news_results": [{"title": "News", "link": "https://example.com/news"}], + } + serpapi_http.client.search.return_value = tools.serpapi.SerpResults( + data, + client=serpapi_http.client, + ) + + result = await tools._serpapi_request( + {"websearch_serpapi_key": ["test-key"]}, + {"q": "OpenAI"}, + engine="google_news", + ) + + assert result == data + assert isinstance(result, dict) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "transport_error", + [ + tools.serpapi.HTTPConnectionError( + requests.ConnectionError( + "Request failed: https://serpapi.com/search.json?api_key=secret-key" + ) + ), + tools.serpapi.TimeoutError( + "Request failed: https://serpapi.com/search.json?api_key=secret-key" + ), + ], +) async def test_serpapi_transport_errors_do_not_expose_credentials( - serpapi_http, error_type + serpapi_http, transport_error ): - serpapi_http.session.get.side_effect = error_type( - "Request failed: https://serpapi.com/search.json?api_key=secret-key" - ) + serpapi_http.client.search.side_effect = transport_error - with pytest.raises(RuntimeError, match="request failed or timed out") as error: + with pytest.raises(RuntimeError, match="request failed or timed out") as caught: await tools._serpapi_search( {"websearch_serpapi_key": ["secret-key"]}, {"q": "AstrBot"} ) - assert "secret-key" not in str(error.value) - assert error.value.__suppress_context__ + assert "secret-key" not in str(caught.value) + assert caught.value.__suppress_context__ @pytest.mark.asyncio From b6dd9dcc544c324b59c339d77ba34e5f0071addf Mon Sep 17 00:00:00 2001 From: Magenta Date: Sat, 24 Oct 2026 21:55:46 +0200 Subject: [PATCH 2/4] feat: add SerpApi Google News search --- astrbot/core/astr_main_agent.py | 3 + astrbot/core/config/default.py | 4 +- astrbot/core/tools/web_search_tools.py | 104 ++++++++++++++++ astrbot/dashboard/services/chat_service.py | 1 + .../dashboard/services/live_chat_service.py | 1 + changelogs/unreleased.md | 2 + .../en-US/features/config-metadata.json | 4 +- .../ja-JP/features/config-metadata.json | 4 +- .../ru-RU/features/config-metadata.json | 4 +- .../zh-CN/features/config-metadata.json | 4 +- docs/en/dev/astrbot-config.md | 4 +- docs/en/use/websearch.md | 2 +- docs/zh/dev/astrbot-config.md | 4 +- docs/zh/use/websearch.md | 2 +- tests/unit/test_astr_main_agent.py | 3 + tests/unit/test_serpapi_search.py | 117 +++++++++++++++++- tests/unit/test_web_search_refs.py | 1 + 17 files changed, 247 insertions(+), 17 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 59183b8a613..1610308187e 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -107,6 +107,7 @@ FirecrawlWebSearchTool, SerpApiFinanceSearchTool, SerpApiImageSearchTool, + SerpApiNewsSearchTool, SerpApiScholarSearchTool, SerpApiWebSearchTool, TavilyExtractWebPageTool, @@ -170,6 +171,7 @@ "web_search_anysearch", "web_search_deepseek", "web_search_serpapi", + "news_search_serpapi", "image_search_serpapi", "finance_search_serpapi", "scholar_search_serpapi", @@ -1312,6 +1314,7 @@ async def _apply_web_search_tools( req.func_tool.add_tool(tool_mgr.get_builtin_tool(DeepSeekFetchWebPageTool)) elif provider == "serpapi": req.func_tool.add_tool(tool_mgr.get_builtin_tool(SerpApiWebSearchTool)) + req.func_tool.add_tool(tool_mgr.get_builtin_tool(SerpApiNewsSearchTool)) req.func_tool.add_tool(tool_mgr.get_builtin_tool(SerpApiImageSearchTool)) req.func_tool.add_tool(tool_mgr.get_builtin_tool(SerpApiFinanceSearchTool)) req.func_tool.add_tool(tool_mgr.get_builtin_tool(SerpApiScholarSearchTool)) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 150804573a8..ee27298071a 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -3695,7 +3695,7 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "AnySearch", ], "subtitles": [ - "Google 网页、图片、学术和财经搜索", + "Google 网页、新闻、图片、学术和财经搜索", "DeepSeek 官方搜索接口", "", "", @@ -3799,7 +3799,7 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "description": "SerpApi API Key", "type": "list", "items": {"type": "string"}, - "hint": "支持 Google 网页、图片、学术和财经搜索。可添加多个 Key 轮询。获取 Key:[https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)", + "hint": "支持 Google 网页、新闻、图片、学术和财经搜索。可添加多个 Key 轮询。获取 Key:[https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)", "secret": True, "condition": { "provider_settings.websearch_provider": "serpapi", diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index bdb68c0f793..17e645aeedb 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -27,6 +27,7 @@ "exa_get_contents", "web_search_anysearch", "web_search_serpapi", + "news_search_serpapi", "image_search_serpapi", "finance_search_serpapi", "scholar_search_serpapi", @@ -611,6 +612,108 @@ async def call(self, context, **kwargs) -> ToolExecResult: return _search_result_payload(results[:max_results]) +@builtin_tool(config=_SERPAPI_WEB_SEARCH_TOOL_CONFIG) +@pydantic_dataclass +class SerpApiNewsSearchTool(FunctionTool[AstrAgentContext]): + name: str = "news_search_serpapi" + description: str = ( + "Search current news using the SerpApi Google News API. Returns article " + "titles, source URLs, publishers, authors, snippets, and publication times." + ) + parameters: dict = Field( + default_factory=lambda: { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Required. News search query. Google News operators such as site: and when: are supported.", + }, + "max_results": { + "type": "integer", + "description": "Maximum articles to return, 1-10. Default is 5; fewer may be available.", + }, + "country": { + "type": "string", + "description": "Optional. Google News country code, such as us, uk, or cn.", + }, + "search_lang": { + "type": "string", + "description": "Optional. Google News language code, such as en or zh-cn.", + }, + }, + "required": ["query"], + } + ) + + async def call(self, context, **kwargs) -> ToolExecResult: + """Search Google News and preserve article metadata for citations. + + Args: + context: Agent context containing the active profile settings. + **kwargs: News query, result limit, country, and language. + + Returns: + News results as JSON, or a configuration or validation error. + + Raises: + RuntimeError: If the search request fails. + """ + _, provider_settings, _ = _get_runtime(context) + if not provider_settings.get("websearch_serpapi_key", []): + return "Error: SerpApi API key is not configured in AstrBot." + + query = kwargs["query"].strip() + if not query: + return "Error: query must not be empty." + max_results = max(1, min(int(kwargs.get("max_results", 5)), 10)) + payload = {"q": query} + for name, parameter in (("country", "gl"), ("search_lang", "hl")): + if kwargs.get(name): + payload[parameter] = kwargs[name] + data = await _serpapi_request(provider_settings, payload, engine="google_news") + + ref_uuid = str(uuid.uuid4())[:4] + results = [] + seen_urls = set() + for group in data.get("news_results", []): + articles = [] + if group.get("link"): + articles.append(group) + if isinstance(group.get("highlight"), dict): + articles.append(group["highlight"]) + articles.extend( + story for story in group.get("stories", []) if isinstance(story, dict) + ) + for article in articles: + source_url = article.get("link") + if not source_url or source_url in seen_urls: + continue + seen_urls.add(source_url) + source = article.get("source") or {} + results.append( + { + "title": article.get("title") or "", + "url": source_url, + "snippet": article.get("snippet") or "", + "source": source.get("name") or source.get("title") or "", + "authors": source.get("authors") or [], + "published_at": article.get("iso_date") + or article.get("date") + or "", + "thumbnail_url": article.get("thumbnail") + or article.get("thumbnail_small") + or "", + "index": f"{ref_uuid}.{len(results) + 1}", + } + ) + _cache_favicon(source_url, source.get("icon")) + if len(results) >= max_results: + break + if len(results) >= max_results: + break + return json.dumps({"results": results}, ensure_ascii=False) + + @builtin_tool(config=_SERPAPI_WEB_SEARCH_TOOL_CONFIG) @pydantic_dataclass class SerpApiImageSearchTool(FunctionTool[AstrAgentContext]): @@ -2135,6 +2238,7 @@ async def call(self, context, **kwargs) -> ToolExecResult: "ExaWebSearchTool", "SerpApiFinanceSearchTool", "SerpApiImageSearchTool", + "SerpApiNewsSearchTool", "SerpApiScholarSearchTool", "SerpApiWebSearchTool", "TavilyExtractWebPageTool", diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index fe3e24264cd..7c4a8a3f958 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -278,6 +278,7 @@ def extract_web_search_refs(accumulated_text: str, accumulated_parts: list) -> d "web_search_brave", "web_search_deepseek", "web_search_serpapi", + "news_search_serpapi", "image_search_serpapi", "finance_search_serpapi", "scholar_search_serpapi", diff --git a/astrbot/dashboard/services/live_chat_service.py b/astrbot/dashboard/services/live_chat_service.py index 2b17c5e7c76..c34986923b8 100644 --- a/astrbot/dashboard/services/live_chat_service.py +++ b/astrbot/dashboard/services/live_chat_service.py @@ -289,6 +289,7 @@ def extract_web_search_refs(accumulated_text: str, accumulated_parts: list) -> d "web_search_brave", "web_search_deepseek", "web_search_serpapi", + "news_search_serpapi", "image_search_serpapi", "finance_search_serpapi", "scholar_search_serpapi", diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 2e6d55d8881..4863b132449 100644 --- a/changelogs/unreleased.md +++ b/changelogs/unreleased.md @@ -2,6 +2,8 @@ - Simplify Context Management Strategy settings: Handling for History Limits or Context Window Pressure → Compression Strategy; Model Provider ID for Context Compression → Compression Model. Move maximum turns, discarded turns, compression instruction, recent-context ratio and fallback context window size under the collapsed More Settings section. Preserve existing configuration keys, defaults and runtime behavior. +- - Use the official SerpApi Python SDK and add Google News search with publisher, author, publication time, and WebUI citation support. + - Add compact data-cleanup checkboxes to the existing bot deletion dialog. Custom rules, preferences, configuration routes and scheduled tasks are selected by default; AI conversations, message history, local traces and usage statistics are retained unless selected. Preserve shared resources and match adapter IDs exactly. - Resolve Lark group-message sender names through the existing user-info lookup, sharing the same per-user cache with private chats (30 minutes for successful names and one minute for failures). Application senders skip user-directory lookups. diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index c3ed8c06be6..9fd9a516ccd 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -188,7 +188,7 @@ "AnySearch" ], "subtitles": [ - "Google web, image, scholar, and finance search", + "Google web, news, image, scholar, and finance search", "Official DeepSeek search API", "", "", @@ -232,7 +232,7 @@ }, "websearch_serpapi_key": { "description": "SerpApi API Key", - "hint": "Supports Google web, image, scholar, and finance search. Multiple keys can be added for rotation. Get a key: [https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)" + "hint": "Supports Google web, news, image, scholar, and finance search. Multiple keys can be added for rotation. Get a key: [https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)" }, "websearch_deepseek_key": { "description": "DeepSeek API Key", diff --git a/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json b/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json index e798966582d..f13ca731991 100644 --- a/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ja-JP/features/config-metadata.json @@ -276,7 +276,7 @@ "AnySearch" ], "subtitles": [ - "Google のウェブ・画像・学術・金融検索", + "Google のウェブ・ニュース・画像・学術・金融検索", "DeepSeek 公式検索 API", "", "", @@ -320,7 +320,7 @@ }, "websearch_serpapi_key": { "description": "SerpApi API Key", - "hint": "Google のウェブ・画像・学術・金融検索に対応。複数の Key を追加してローテーションできます。Key の取得:[https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)" + "hint": "Google のウェブ・ニュース・画像・学術・金融検索に対応。複数の Key を追加してローテーションできます。Key の取得:[https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)" }, "websearch_deepseek_key": { "description": "DeepSeek API Key", diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 5b7ce75ed72..46bd0d7ce36 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -276,7 +276,7 @@ "AnySearch" ], "subtitles": [ - "Веб-поиск, изображения, научные публикации и финансы Google", + "Веб-поиск, новости, изображения, научные публикации и финансы Google", "Официальный поисковый API DeepSeek", "", "", @@ -320,7 +320,7 @@ }, "websearch_serpapi_key": { "description": "SerpApi API Key", - "hint": "Поддерживает веб-поиск, поиск изображений, научных публикаций и финансовых данных Google. Можно добавить несколько ключей для ротации. Получить ключ: [https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)" + "hint": "Поддерживает веб-поиск, поиск новостей, изображений, научных публикаций и финансовых данных Google. Можно добавить несколько ключей для ротации. Получить ключ: [https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)" }, "websearch_deepseek_key": { "description": "API-ключ DeepSeek", diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index b50068469ed..e588d8c0c9d 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -204,7 +204,7 @@ "AnySearch" ], "subtitles": [ - "Google 网页、图片、学术和财经搜索", + "Google 网页、新闻、图片、学术和财经搜索", "DeepSeek 官方搜索接口", "", "", @@ -248,7 +248,7 @@ }, "websearch_serpapi_key": { "description": "SerpApi API Key", - "hint": "支持 Google 网页、图片、学术和财经搜索。可添加多个 Key 轮询。获取 Key:[https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)" + "hint": "支持 Google 网页、新闻、图片、学术和财经搜索。可添加多个 Key 轮询。获取 Key:[https://serpapi.com/manage-api-key](https://serpapi.com/manage-api-key)" }, "websearch_deepseek_key": { "description": "DeepSeek API Key", diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index 5c790c9e2ae..8395eb06ffb 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -296,7 +296,7 @@ Whether to enable AstrBot's built-in web search capability. Default is `false`. Web search provider type. Default is `tavily`. Currently supports `serpapi`, `deepseek`, `tavily`, `bocha`, `baidu_ai_search`, `brave`, `firecrawl`, `exa`, and `anysearch`. -- `serpapi`: Uses SerpApi Google Search, Google Images Light, Google Finance, and Google Scholar. +- `serpapi`: Uses SerpApi Google Search, Google News, Google Images Light, Google Finance, and Google Scholar. - `deepseek`: Uses the DeepSeek Search API. - `tavily`: Uses the Tavily search engine. - `bocha`: Uses the BoCha search engine. @@ -328,7 +328,7 @@ API Key list for the DeepSeek search engine. Required when using `deepseek` as t #### `provider_settings.websearch_serpapi_key` -API Key list shared by SerpApi Google Search, Google Images Light, Google Finance, and Google Scholar. Required when using `serpapi` as the web search provider. Obtain a key at [SerpApi](https://serpapi.com/manage-api-key). Multiple keys rotate, with failover on authentication and quota errors. +API Key list shared by SerpApi Google Search, Google News, Google Images Light, Google Finance, and Google Scholar. Required when using `serpapi` as the web search provider. Obtain a key at [SerpApi](https://serpapi.com/manage-api-key). Multiple keys rotate, with failover on authentication and quota errors. #### `provider_settings.web_search_link` diff --git a/docs/en/use/websearch.md b/docs/en/use/websearch.md index 6642246d048..262f464cd9c 100644 --- a/docs/en/use/websearch.md +++ b/docs/en/use/websearch.md @@ -31,7 +31,7 @@ AstrBot supports nine providers. Configure one to start; you do not need an acco | Provider | Get a key | Capabilities available in AstrBot | | --- | --- | --- | -| [SerpApi](./websearch/serpapi.md) | [SerpApi API key](https://serpapi.com/manage-api-key) | Google web search, Images Light, Finance, and Scholar | +| [SerpApi](./websearch/serpapi.md) | [SerpApi API key](https://serpapi.com/manage-api-key) | Google web search, News, Images Light, Finance, and Scholar | | [DeepSeek Search API](./websearch/deepseek.md) | [DeepSeek Platform](https://platform.deepseek.com/api_keys) / [Search Docs](https://api-docs.deepseek.com/guides/search) | Web search and page content extraction from a URL | | Tavily | [Tavily dashboard](https://app.tavily.com/home) | Web search and page content extraction from a URL | | Bocha | [BoCha](https://bochaai.com/) | Web search | diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index ded6964b7ec..588284193e7 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -296,7 +296,7 @@ ID 白名单。填写后,将只处理所填写的 ID 发来的消息事件。 网页搜索提供商类型。默认为 `tavily`。目前支持 `serpapi`、`deepseek`、`tavily`、`bocha`、`baidu_ai_search`、`brave`、`firecrawl`、`exa`、`anysearch`。 -- `serpapi`:使用 SerpApi Google Search、Google Images Light、Google Finance 和 Google Scholar。 +- `serpapi`:使用 SerpApi Google Search、Google News、Google Images Light、Google Finance 和 Google Scholar。 - `deepseek`:使用 DeepSeek Search API。 - `tavily`:使用 Tavily 搜索引擎。 - `bocha`:使用 BoCha 搜索引擎。 @@ -328,7 +328,7 @@ DeepSeek 搜索引擎的 API Key 列表。使用 `deepseek` 作为网页搜索 #### `provider_settings.websearch_serpapi_key` -SerpApi Google Search、Google Images Light、Google Finance 和 Google Scholar 共用的 API Key 列表。使用 `serpapi` 作为网页搜索提供商时需要填写。前往 [SerpApi](https://serpapi.com/manage-api-key) 获取 Key。多个 Key 轮询使用,鉴权或额度错误时会尝试下一个 Key。 +SerpApi Google Search、Google News、Google Images Light、Google Finance 和 Google Scholar 共用的 API Key 列表。使用 `serpapi` 作为网页搜索提供商时需要填写。前往 [SerpApi](https://serpapi.com/manage-api-key) 获取 Key。多个 Key 轮询使用,鉴权或额度错误时会尝试下一个 Key。 #### `provider_settings.web_search_link` diff --git a/docs/zh/use/websearch.md b/docs/zh/use/websearch.md index 5114e85faf3..4489df8ce29 100644 --- a/docs/zh/use/websearch.md +++ b/docs/zh/use/websearch.md @@ -31,7 +31,7 @@ | 提供商 | Key 申请入口 | AstrBot 提供的能力 | | --- | --- | --- | -| [SerpApi](./websearch/serpapi.md) | [SerpApi API Key](https://serpapi.com/manage-api-key) | Google 网页搜索、Images Light 图片搜索、Finance 金融查询、Scholar 学术搜索 | +| [SerpApi](./websearch/serpapi.md) | [SerpApi API Key](https://serpapi.com/manage-api-key) | Google 网页搜索、News 新闻、Images Light 图片搜索、Finance 金融查询、Scholar 学术搜索 | | [DeepSeek Search API](./websearch/deepseek.md) | [DeepSeek 开放平台](https://platform.deepseek.com/api_keys) / [搜索文档](https://api-docs.deepseek.com/zh-cn/guides/search) | 网页搜索、按 URL 提取网页正文 | | Tavily | [Tavily 控制台](https://app.tavily.com/home) | 网页搜索、按 URL 提取网页正文 | | Bocha | [博查](https://bochaai.com/) | 网页搜索 | diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 3089bbc7b34..a02d3054c54 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -799,6 +799,7 @@ async def test_serpapi_injects_all_search_tools(self, mock_event, mock_context): assert req.func_tool.names() == [ "web_search_serpapi", + "news_search_serpapi", "image_search_serpapi", "finance_search_serpapi", "scholar_search_serpapi", @@ -898,6 +899,7 @@ async def test_apply_web_search_tools_adds_search_and_extract_tools( "web_search_tavily", "web_search_deepseek", "web_search_serpapi", + "news_search_serpapi", "image_search_serpapi", "finance_search_serpapi", "scholar_search_serpapi", @@ -1583,6 +1585,7 @@ async def test_ensure_tools_from_persona(self, mock_event, mock_context): "serpapi", [ "web_search_serpapi", + "news_search_serpapi", "image_search_serpapi", "finance_search_serpapi", "scholar_search_serpapi", diff --git a/tests/unit/test_serpapi_search.py b/tests/unit/test_serpapi_search.py index 2660cb29fe3..dc50db1fc01 100644 --- a/tests/unit/test_serpapi_search.py +++ b/tests/unit/test_serpapi_search.py @@ -512,10 +512,123 @@ async def test_serpapi_tool_limits_first_page_results(serpapi_http, limit, expec } +@pytest.mark.asyncio +async def test_serpapi_news_maps_articles_and_metadata( + serpapi_http, serpapi_context, monkeypatch +): + cache = {} + monkeypatch.setitem(tools.sp.temporary_cache, "_ws_favicon", cache) + serpapi_http.client.search.return_value = { + "news_results": [ + { + "title": "AstrBot adds a news search tool", + "link": "https://example.com/astrbot-news", + "snippet": "The project added Google News support.", + "source": { + "name": "Example News", + "icon": "https://example.com/favicon.ico", + "authors": ["A. Reporter"], + }, + "iso_date": "2026-10-24T08:00:00Z", + "thumbnail": "https://example.com/news.jpg", + }, + {"title": "Group without a direct article", "stories": []}, + ] + } + + result = json.loads( + await tools.SerpApiNewsSearchTool().call( + serpapi_context, + query="AstrBot", + country="us", + search_lang="en", + ) + )["results"] + + assert serpapi_http.client.search.call_args.args[0] == { + "engine": "google_news", + "q": "AstrBot", + "gl": "us", + "hl": "en", + } + assert result == [ + { + "title": "AstrBot adds a news search tool", + "url": "https://example.com/astrbot-news", + "snippet": "The project added Google News support.", + "source": "Example News", + "authors": ["A. Reporter"], + "published_at": "2026-10-24T08:00:00Z", + "thumbnail_url": "https://example.com/news.jpg", + "index": result[0]["index"], + } + ] + assert result[0]["index"].endswith(".1") + assert cache == { + "https://example.com/astrbot-news": "https://example.com/favicon.ico" + } + + +@pytest.mark.asyncio +async def test_serpapi_news_flattens_groups_deduplicates_and_limits( + serpapi_http, serpapi_context +): + serpapi_http.client.search.return_value = { + "news_results": [ + { + "highlight": { + "title": "Highlighted article", + "link": "https://example.com/highlight", + }, + "stories": [ + { + "title": "Duplicate article", + "link": "https://example.com/highlight", + }, + { + "title": "Related article", + "link": "https://example.com/related", + "date": "Oct 24, 2026", + }, + ], + } + ] + } + + result = json.loads( + await tools.SerpApiNewsSearchTool().call( + serpapi_context, query="AstrBot", max_results=2 + ) + )["results"] + + assert [item["url"] for item in result] == [ + "https://example.com/highlight", + "https://example.com/related", + ] + assert result[1]["published_at"] == "Oct 24, 2026" + + +@pytest.mark.asyncio +async def test_serpapi_news_validates_query_before_request( + serpapi_http, serpapi_context +): + result = await tools.SerpApiNewsSearchTool().call(serpapi_context, query=" ") + + assert result == "Error: query must not be empty." + serpapi_http.factory.assert_not_called() + + @pytest.mark.asyncio @pytest.mark.parametrize("status", [401, 403, 429]) @pytest.mark.parametrize( - "engine", ["google", "google_images_light", "google_finance", "google_scholar"] + "engine", + [ + "google", + "google_news", + "google_images_light", + "google_finance", + "google_scholar", + ], ) async def test_serpapi_rotates_and_retries_key_errors(serpapi_http, status, engine): serpapi_http.client.search.side_effect = [ @@ -653,6 +766,7 @@ async def test_serpapi_transport_errors_do_not_expose_credentials( "tool_class", [ tools.SerpApiWebSearchTool, + tools.SerpApiNewsSearchTool, tools.SerpApiImageSearchTool, tools.SerpApiFinanceSearchTool, tools.SerpApiScholarSearchTool, @@ -683,6 +797,7 @@ async def test_serpapi_requires_credentials_before_request(serpapi_http, tool_cl "tool_class", [ tools.SerpApiWebSearchTool, + tools.SerpApiNewsSearchTool, tools.SerpApiImageSearchTool, tools.SerpApiFinanceSearchTool, tools.SerpApiScholarSearchTool, diff --git a/tests/unit/test_web_search_refs.py b/tests/unit/test_web_search_refs.py index d805e229888..3132f244ff1 100644 --- a/tests/unit/test_web_search_refs.py +++ b/tests/unit/test_web_search_refs.py @@ -19,6 +19,7 @@ [ "web_search_deepseek", "web_search_serpapi", + "news_search_serpapi", "image_search_serpapi", "finance_search_serpapi", "scholar_search_serpapi", From 03a8a2370b68acc71656e5f70db2949f48d96762 Mon Sep 17 00:00:00 2001 From: Magenta Date: Mon, 26 Oct 2026 20:48:56 +0100 Subject: [PATCH 3/4] refactor: restore direct HTTP requests for SerpApi --- astrbot/core/tools/web_search_tools.py | 61 ++-- pyproject.toml | 1 - tests/unit/test_serpapi_search.py | 432 +++++++++++++------------ 3 files changed, 245 insertions(+), 249 deletions(-) diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index 17e645aeedb..fa7eea312f9 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -6,7 +6,6 @@ from urllib.parse import quote, urlsplit import aiohttp -import serpapi from pydantic import Field from pydantic.dataclasses import dataclass as pydantic_dataclass @@ -477,41 +476,35 @@ async def _serpapi_request( for _ in range(len(keys)): api_key = await _SERPAPI_KEY_ROTATOR.get(provider_settings) try: - client = serpapi.Client(api_key=api_key, timeout=60) - try: - data = await asyncio.to_thread( - client.search, - {**payload, "engine": engine}, - ) - finally: - client.session.close() - except serpapi.HTTPConnectionError: + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get( + "https://serpapi.com/search.json", + params={**payload, "engine": engine, "api_key": api_key}, + timeout=aiohttp.ClientTimeout(total=60), + ) as response: + if response.status == 200: + data = await response.json() + status = data.get("search_metadata", {}).get("status") + # SerpApi also returns an error message for successful + # searches with no results; these should remain empty. + if status == "Error" or ( + data.get("error") and status != "Success" + ): + reason = str(data.get("error") or "Unknown API error.") + raise RuntimeError( + f"SerpApi search failed: {reason.replace(api_key, '[redacted]')}" + ) + return data + reason = (await response.text()).replace(api_key, "[redacted]") + last_error = RuntimeError( + f"SerpApi search failed: {reason}, status: {response.status}" + ) + if response.status in {401, 403, 429}: + continue + raise last_error + except (aiohttp.ClientError, asyncio.TimeoutError): # Transport errors can include the request URL with its API key. raise RuntimeError("SerpApi search request failed or timed out.") from None - except serpapi.TimeoutError: - raise RuntimeError("SerpApi search request failed or timed out.") from None - except serpapi.HTTPError as exc: - reason = str(exc.error or exc).replace(api_key, "[redacted]") - last_error = RuntimeError( - f"SerpApi search failed: {reason}, status: {exc.status_code}" - ) - if exc.status_code in {401, 403, 429}: - continue - raise last_error from None - - if isinstance(data, serpapi.SerpResults): - data = data.as_dict() - elif not isinstance(data, dict): - raise RuntimeError("SerpApi search returned an unexpected response.") - status = data.get("search_metadata", {}).get("status") - # SerpApi also returns an error message for successful searches with no - # results; these should remain empty. - if status == "Error" or (data.get("error") and status != "Success"): - reason = str(data.get("error") or "Unknown API error.") - raise RuntimeError( - f"SerpApi search failed: {reason.replace(api_key, '[redacted]')}" - ) - return data if last_error is not None: raise last_error diff --git a/pyproject.toml b/pyproject.toml index 39a1c42f7f1..e9f89f6fe1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,6 @@ dependencies = [ "xlrd>=2.0.2", "opentelemetry-api>=1.45.1,<2", "opentelemetry-sdk>=1.45.1,<2", - "serpapi>=1.1.2", ] [dependency-groups] diff --git a/tests/unit/test_serpapi_search.py b/tests/unit/test_serpapi_search.py index dc50db1fc01..3ebe26752fb 100644 --- a/tests/unit/test_serpapi_search.py +++ b/tests/unit/test_serpapi_search.py @@ -1,50 +1,53 @@ +import asyncio import json from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock +import aiohttp import pytest -import requests from astrbot.core.tools import registry from astrbot.core.tools import web_search_tools as tools -def _http_error(status: int, reason: str): - """Build a SerpApi SDK HTTP error. +def _response(status=200, data=None, reason=""): + """Build a mock aiohttp response. Args: status: HTTP status code. + data: JSON response body. reason: Text response body for HTTP errors. Returns: - An SDK exception carrying the status and API error message. + An asynchronous response context manager. """ - response = requests.Response() - response.status_code = status - response._content = json.dumps({"error": reason}).encode() - response.headers["Content-Type"] = "application/json" - return tools.serpapi.HTTPError(requests.HTTPError(reason, response=response)) + response = MagicMock(status=status) + response.__aenter__.return_value = response + response.json = AsyncMock(return_value=data or {}) + response.text = AsyncMock(return_value=reason) + return response @pytest.fixture def serpapi_http(monkeypatch): - """Mock the SerpApi SDK client and reset key rotation. + """Mock the SerpApi transport and reset key rotation. Args: monkeypatch: Pytest patch fixture. Returns: - The client and client factory mocks. + The session and session factory mocks. """ - client = MagicMock() - factory = MagicMock(return_value=client) - monkeypatch.setattr(tools.serpapi, "Client", factory) + session = MagicMock() + session.__aenter__.return_value = session + factory = MagicMock(return_value=session) + monkeypatch.setattr(tools.aiohttp, "ClientSession", factory) monkeypatch.setattr( tools, "_SERPAPI_KEY_ROTATOR", tools._KeyRotator("websearch_serpapi_key", "SerpApi"), ) - return SimpleNamespace(client=client, factory=factory) + return SimpleNamespace(session=session, factory=factory) @pytest.fixture @@ -93,7 +96,7 @@ async def test_serpapi_finance_preserves_quotes_fundamentals_and_sources( ], "graph": [{"price": 229.0, "date": "Oct 16 2026, 09:30 AM UTC-04:00"}], } - serpapi_http.client.search.return_value = data + serpapi_http.session.get.return_value = _response(data=data) result = json.loads( await tools.SerpApiFinanceSearchTool().call( @@ -101,8 +104,9 @@ async def test_serpapi_finance_preserves_quotes_fundamentals_and_sources( ) ) - assert serpapi_http.client.search.call_args.args[0] == { + assert serpapi_http.session.get.call_args.kwargs["params"] == { "engine": "google_finance", + "api_key": "test-key", "q": "AAPL:NASDAQ", "hl": "en", } @@ -118,11 +122,13 @@ async def test_serpapi_finance_preserves_quotes_fundamentals_and_sources( @pytest.mark.asyncio async def test_serpapi_finance_returns_requested_history(serpapi_http, serpapi_context): graph = [{"price": i, "currency": "USD", "date": f"Day {i}"} for i in range(120)] - serpapi_http.client.search.return_value = { - "summary": {"title": "Apple Inc", "price": "$230.00"}, - "graph": graph, - "key_events": [{"title": "Earnings", "date": "Day 100"}], - } + serpapi_http.session.get.return_value = _response( + data={ + "summary": {"title": "Apple Inc", "price": "$230.00"}, + "graph": graph, + "key_events": [{"title": "Earnings", "date": "Day 100"}], + } + ) result = json.loads( await tools.SerpApiFinanceSearchTool().call( @@ -130,7 +136,7 @@ async def test_serpapi_finance_returns_requested_history(serpapi_http, serpapi_c ) ) - assert serpapi_http.client.search.call_args.args[0]["window"] == "1Y" + assert serpapi_http.session.get.call_args.kwargs["params"]["window"] == "1Y" assert result["graph"] == graph assert result["key_events"] == [{"title": "Earnings", "date": "Day 100"}] assert ( @@ -149,7 +155,7 @@ async def test_serpapi_finance_preserves_suggestions_for_unknown_symbol( "link": "https://www.google.com/finance/quote/MSFT:NASDAQ", } ] - serpapi_http.client.search.return_value = {"suggestions": suggestions} + serpapi_http.session.get.return_value = _response(data={"suggestions": suggestions}) result = json.loads( await tools.SerpApiFinanceSearchTool().call(serpapi_context, query="MSFTR:") @@ -187,24 +193,26 @@ async def test_serpapi_scholar_preserves_publication_metadata_and_filters( "file_format": "PDF", } ] - serpapi_http.client.search.return_value = { - "organic_results": [ - { - "title": "A research paper", - "link": "https://example.com/paper", - "snippet": "Abstract excerpt", - "publication_info": publication_info, - "resources": resources, - "inline_links": { - "cited_by": { - "total": 42, - "link": "https://scholar.google.com/scholar?cites=123", + serpapi_http.session.get.return_value = _response( + data={ + "organic_results": [ + { + "title": "A research paper", + "link": "https://example.com/paper", + "snippet": "Abstract excerpt", + "publication_info": publication_info, + "resources": resources, + "inline_links": { + "cited_by": { + "total": 42, + "link": "https://scholar.google.com/scholar?cites=123", + }, + "serpapi_cite_link": "https://serpapi.com/private-cite", }, - "serpapi_cite_link": "https://serpapi.com/private-cite", - }, - } - ] - } + } + ] + } + ) result = json.loads( await tools.SerpApiScholarSearchTool().call( @@ -217,8 +225,9 @@ async def test_serpapi_scholar_preserves_publication_metadata_and_filters( ) )["results"] - assert serpapi_http.client.search.call_args.args[0] == { + assert serpapi_http.session.get.call_args.kwargs["params"] == { "engine": "google_scholar", + "api_key": "test-key", "q": "retrieval augmented generation", "num": 3, "hl": "en", @@ -243,9 +252,9 @@ async def test_serpapi_scholar_preserves_publication_metadata_and_filters( async def test_serpapi_scholar_limits_results_and_handles_citation_only_entries( serpapi_http, serpapi_context, limit, expected ): - serpapi_http.client.search.return_value = { - "organic_results": [{"title": f"Paper {i}"} for i in range(22)] - } + serpapi_http.session.get.return_value = _response( + data={"organic_results": [{"title": f"Paper {i}"} for i in range(22)]} + ) kwargs = {} if limit is None else {"max_results": limit} results = json.loads( @@ -255,7 +264,7 @@ async def test_serpapi_scholar_limits_results_and_handles_citation_only_entries( )["results"] assert len(results) == expected - assert serpapi_http.client.search.call_args.args[0]["num"] == expected + assert serpapi_http.session.get.call_args.kwargs["params"]["num"] == expected assert results[0]["url"] == "https://scholar.google.com/scholar?q=Paper%200" assert results[0]["cited_by_count"] is None @@ -264,14 +273,16 @@ async def test_serpapi_scholar_limits_results_and_handles_citation_only_entries( async def test_serpapi_scholar_uses_full_text_link_if_publisher_link_is_missing( serpapi_http, serpapi_context ): - serpapi_http.client.search.return_value = { - "organic_results": [ - { - "title": "Paper", - "resources": [{"link": "https://example.com/paper.pdf"}], - } - ] - } + serpapi_http.session.get.return_value = _response( + data={ + "organic_results": [ + { + "title": "Paper", + "resources": [{"link": "https://example.com/paper.pdf"}], + } + ] + } + ) result = json.loads( await tools.SerpApiScholarSearchTool().call( @@ -280,8 +291,8 @@ async def test_serpapi_scholar_uses_full_text_link_if_publisher_link_is_missing( )["results"] assert result[0]["url"] == "https://example.com/paper.pdf" - assert serpapi_http.client.search.call_args.args[0]["as_ylo"] == 2024 - assert "as_yhi" not in serpapi_http.client.search.call_args.args[0] + assert serpapi_http.session.get.call_args.kwargs["params"]["as_ylo"] == 2024 + assert "as_yhi" not in serpapi_http.session.get.call_args.kwargs["params"] @pytest.mark.asyncio @@ -312,7 +323,10 @@ async def test_serpapi_scholar_validates_query_and_years( async def test_serpapi_specialized_search_handles_empty_and_failed_responses( serpapi_http, serpapi_context, tool_class ): - serpapi_http.client.search.side_effect = [{}, _http_error(503, "API unavailable")] + serpapi_http.session.get.side_effect = [ + _response(), + _response(503, reason="API unavailable"), + ] result = json.loads(await tool_class().call(serpapi_context, query="example")) @@ -327,29 +341,31 @@ async def test_serpapi_images_light_maps_images_and_sources( ): cache = {} monkeypatch.setitem(tools.sp.temporary_cache, "_ws_favicon", cache) - serpapi_http.client.search.return_value = { - "images_results": [ - {"title": "Missing image"}, - { - "title": "AstrBot 标志", - "source": "Example", - "link": "https://example.com/article", - "original": "https://example.com/image.png", - "thumbnail": "https://example.com/thumb.png", - "original_width": 1200, - "original_height": 800, - "source_logo": "https://example.com/favicon.ico", - }, - { - "original": "x-raw-image:///pdf-image", - "thumbnail": "https://example.com/pdf-thumb.png", - "original_width": 500, - "original_height": 300, - }, - {"serpapi_thumbnail": "https://example.com/serpapi-thumb.png"}, - {"original": "data:image/png;base64,invalid"}, - ] - } + serpapi_http.session.get.return_value = _response( + data={ + "images_results": [ + {"title": "Missing image"}, + { + "title": "AstrBot 标志", + "source": "Example", + "link": "https://example.com/article", + "original": "https://example.com/image.png", + "thumbnail": "https://example.com/thumb.png", + "original_width": 1200, + "original_height": 800, + "source_logo": "https://example.com/favicon.ico", + }, + { + "original": "x-raw-image:///pdf-image", + "thumbnail": "https://example.com/pdf-thumb.png", + "original_width": 500, + "original_height": 300, + }, + {"serpapi_thumbnail": "https://example.com/serpapi-thumb.png"}, + {"original": "data:image/png;base64,invalid"}, + ] + } + ) result = json.loads( await tools.SerpApiImageSearchTool().call( @@ -357,8 +373,9 @@ async def test_serpapi_images_light_maps_images_and_sources( ) )["results"] - assert serpapi_http.client.search.call_args.args[0] == { + assert serpapi_http.session.get.call_args.kwargs["params"] == { "engine": "google_images_light", + "api_key": "test-key", "q": "AstrBot logo", "gl": "us", "hl": "en", @@ -389,11 +406,13 @@ async def test_serpapi_images_light_maps_images_and_sources( async def test_serpapi_images_light_limits_results( serpapi_http, serpapi_context, limit, expected ): - serpapi_http.client.search.return_value = { - "images_results": [ - {"original": f"https://example.com/{i}.png"} for i in range(12) - ] - } + serpapi_http.session.get.return_value = _response( + data={ + "images_results": [ + {"original": f"https://example.com/{i}.png"} for i in range(12) + ] + } + ) kwargs = {} if limit is None else {"max_results": limit} result = json.loads( @@ -403,8 +422,9 @@ async def test_serpapi_images_light_limits_results( ) assert len(result["results"]) == expected - assert serpapi_http.client.search.call_args.args[0] == { + assert serpapi_http.session.get.call_args.kwargs["params"] == { "engine": "google_images_light", + "api_key": "test-key", "q": "AstrBot", } @@ -413,10 +433,12 @@ async def test_serpapi_images_light_limits_results( async def test_serpapi_images_light_handles_empty_success( serpapi_http, serpapi_context ): - serpapi_http.client.search.return_value = { - "search_metadata": {"status": "Success"}, - "error": "No image results found.", - } + serpapi_http.session.get.return_value = _response( + data={ + "search_metadata": {"status": "Success"}, + "error": "No image results found.", + } + ) result = await tools.SerpApiImageSearchTool().call(serpapi_context, query="AstrBot") @@ -425,7 +447,7 @@ async def test_serpapi_images_light_handles_empty_success( @pytest.mark.asyncio async def test_serpapi_images_light_surfaces_api_errors(serpapi_http, serpapi_context): - serpapi_http.client.search.side_effect = _http_error(503, "Search failed") + serpapi_http.session.get.return_value = _response(503, reason="Search failed") with pytest.raises(RuntimeError, match="status: 503"): await tools.SerpApiImageSearchTool().call(serpapi_context, query="AstrBot") @@ -433,19 +455,21 @@ async def test_serpapi_images_light_surfaces_api_errors(serpapi_http, serpapi_co @pytest.mark.asyncio async def test_serpapi_tool_maps_query_results_and_citations(serpapi_http, monkeypatch): - serpapi_http.client.search.return_value = { - "search_metadata": {"status": "Success"}, - "organic_results": [ - {"title": "Missing link"}, - { - "title": "AstrBot 搜索", - "link": "https://example.com", - "snippet": "Search result", - "favicon": "https://example.com/favicon.ico", - }, - {"link": "https://example.org", "title": None, "snippet": None}, - ], - } + serpapi_http.session.get.return_value = _response( + data={ + "search_metadata": {"status": "Success"}, + "organic_results": [ + {"title": "Missing link"}, + { + "title": "AstrBot 搜索", + "link": "https://example.com", + "snippet": "Search result", + "favicon": "https://example.com/favicon.ico", + }, + {"link": "https://example.org", "title": None, "snippet": None}, + ], + } + ) cache = {} monkeypatch.setattr(tools.sp, "temporary_cache", {"_ws_favicon": cache}) config = {"provider_settings": {"websearch_serpapi_key": ["test-key"]}} @@ -468,15 +492,18 @@ async def test_serpapi_tool_maps_query_results_and_citations(serpapi_http, monke )["results"] get_config.assert_called_once_with(umo="test:private:session") - serpapi_http.factory.assert_called_once_with(api_key="test-key", timeout=60) - serpapi_http.client.session.close.assert_called_once_with() - assert serpapi_http.client.search.call_args.args[0] == { + serpapi_http.factory.assert_called_once_with(trust_env=True) + call = serpapi_http.session.get.call_args + assert call.args == ("https://serpapi.com/search.json",) + assert call.kwargs["params"] == { "engine": "google", + "api_key": "test-key", "q": "AstrBot 搜索", "gl": "cn", "hl": "zh-cn", "location": "Shanghai, China", } + assert call.kwargs["timeout"].total == 60 assert result[0] == { "title": "AstrBot 搜索", "url": "https://example.com", @@ -491,9 +518,11 @@ async def test_serpapi_tool_maps_query_results_and_citations(serpapi_http, monke @pytest.mark.asyncio @pytest.mark.parametrize("limit,expected", [(None, 5), (-2, 1), (3, 3), (100, 10)]) async def test_serpapi_tool_limits_first_page_results(serpapi_http, limit, expected): - serpapi_http.client.search.return_value = { - "organic_results": [{"link": f"https://example.com/{i}"} for i in range(12)] - } + serpapi_http.session.get.return_value = _response( + data={ + "organic_results": [{"link": f"https://example.com/{i}"} for i in range(12)] + } + ) config = {"provider_settings": {"websearch_serpapi_key": ["test-key"]}} context = SimpleNamespace( context=SimpleNamespace( @@ -506,8 +535,9 @@ async def test_serpapi_tool_limits_first_page_results(serpapi_http, limit, expec result = await tools.SerpApiWebSearchTool().call(context, query="AstrBot", **kwargs) assert len(json.loads(result)["results"]) == expected - assert serpapi_http.client.search.call_args.args[0] == { + assert serpapi_http.session.get.call_args.kwargs["params"] == { "engine": "google", + "api_key": "test-key", "q": "AstrBot", } @@ -518,23 +548,25 @@ async def test_serpapi_news_maps_articles_and_metadata( ): cache = {} monkeypatch.setitem(tools.sp.temporary_cache, "_ws_favicon", cache) - serpapi_http.client.search.return_value = { - "news_results": [ - { - "title": "AstrBot adds a news search tool", - "link": "https://example.com/astrbot-news", - "snippet": "The project added Google News support.", - "source": { - "name": "Example News", - "icon": "https://example.com/favicon.ico", - "authors": ["A. Reporter"], + serpapi_http.session.get.return_value = _response( + data={ + "news_results": [ + { + "title": "AstrBot adds a news search tool", + "link": "https://example.com/astrbot-news", + "snippet": "The project added Google News support.", + "source": { + "name": "Example News", + "icon": "https://example.com/favicon.ico", + "authors": ["A. Reporter"], + }, + "iso_date": "2026-10-24T08:00:00Z", + "thumbnail": "https://example.com/news.jpg", }, - "iso_date": "2026-10-24T08:00:00Z", - "thumbnail": "https://example.com/news.jpg", - }, - {"title": "Group without a direct article", "stories": []}, - ] - } + {"title": "Group without a direct article", "stories": []}, + ] + } + ) result = json.loads( await tools.SerpApiNewsSearchTool().call( @@ -545,8 +577,9 @@ async def test_serpapi_news_maps_articles_and_metadata( ) )["results"] - assert serpapi_http.client.search.call_args.args[0] == { + assert serpapi_http.session.get.call_args.kwargs["params"] == { "engine": "google_news", + "api_key": "test-key", "q": "AstrBot", "gl": "us", "hl": "en", @@ -573,27 +606,29 @@ async def test_serpapi_news_maps_articles_and_metadata( async def test_serpapi_news_flattens_groups_deduplicates_and_limits( serpapi_http, serpapi_context ): - serpapi_http.client.search.return_value = { - "news_results": [ - { - "highlight": { - "title": "Highlighted article", - "link": "https://example.com/highlight", - }, - "stories": [ - { - "title": "Duplicate article", + serpapi_http.session.get.return_value = _response( + data={ + "news_results": [ + { + "highlight": { + "title": "Highlighted article", "link": "https://example.com/highlight", }, - { - "title": "Related article", - "link": "https://example.com/related", - "date": "Oct 24, 2026", - }, - ], - } - ] - } + "stories": [ + { + "title": "Duplicate article", + "link": "https://example.com/highlight", + }, + { + "title": "Related article", + "link": "https://example.com/related", + "date": "Oct 24, 2026", + }, + ], + } + ] + } + ) result = json.loads( await tools.SerpApiNewsSearchTool().call( @@ -631,10 +666,10 @@ async def test_serpapi_news_validates_query_before_request( ], ) async def test_serpapi_rotates_and_retries_key_errors(serpapi_http, status, engine): - serpapi_http.client.search.side_effect = [ - _http_error(status, "Key rejected"), - {}, - {}, + serpapi_http.session.get.side_effect = [ + _response(status, reason="Key rejected"), + _response(), + _response(), ] settings = {"websearch_serpapi_key": ["first-key", "second-key"]} payload = {"q": "AstrBot"} @@ -642,23 +677,22 @@ async def test_serpapi_rotates_and_retries_key_errors(serpapi_http, status, engi assert await tools._serpapi_request(settings, payload, engine=engine) == {} assert await tools._serpapi_request(settings, payload, engine=engine) == {} - assert [call.kwargs["api_key"] for call in serpapi_http.factory.call_args_list] == [ - "first-key", - "second-key", - "first-key", - ] + assert [ + call.kwargs["params"]["api_key"] + for call in serpapi_http.session.get.call_args_list + ] == ["first-key", "second-key", "first-key"] assert all( - call.args[0]["engine"] == engine - for call in serpapi_http.client.search.call_args_list + call.kwargs["params"]["engine"] == engine + for call in serpapi_http.session.get.call_args_list ) assert payload == {"q": "AstrBot"} @pytest.mark.asyncio async def test_serpapi_exhausts_keys_and_redacts_error(serpapi_http): - serpapi_http.client.search.side_effect = [ - _http_error(401, "Invalid first-key"), - _http_error(429, "Quota for second-key"), + serpapi_http.session.get.side_effect = [ + _response(401, reason="Invalid first-key"), + _response(429, reason="Quota for second-key"), ] with pytest.raises(RuntimeError, match="Quota for \\[redacted\\], status: 429"): @@ -666,29 +700,28 @@ async def test_serpapi_exhausts_keys_and_redacts_error(serpapi_http): {"websearch_serpapi_key": ["first-key", "second-key"]}, {"q": "AstrBot"} ) - assert serpapi_http.client.search.call_count == 2 + assert serpapi_http.session.get.call_count == 2 @pytest.mark.asyncio @pytest.mark.parametrize("status", [400, 500, 503]) async def test_serpapi_does_not_retry_other_http_errors(serpapi_http, status): - serpapi_http.client.search.side_effect = _http_error(status, "Request failed") + serpapi_http.session.get.return_value = _response(status, reason="Request failed") with pytest.raises(RuntimeError, match=f"status: {status}"): await tools._serpapi_search( {"websearch_serpapi_key": ["first-key", "second-key"]}, {"q": "AstrBot"} ) - assert serpapi_http.client.search.call_count == 1 + assert serpapi_http.session.get.call_count == 1 @pytest.mark.asyncio @pytest.mark.parametrize("metadata", [{}, {"status": "Error"}]) async def test_serpapi_surfaces_json_errors(serpapi_http, metadata): - serpapi_http.client.search.return_value = { - "search_metadata": metadata, - "error": "Failed for test-key", - } + serpapi_http.session.get.return_value = _response( + data={"search_metadata": metadata, "error": "Failed for test-key"} + ) with pytest.raises(RuntimeError, match="Failed for \\[redacted\\]"): await tools._serpapi_search( @@ -698,11 +731,13 @@ async def test_serpapi_surfaces_json_errors(serpapi_http, metadata): @pytest.mark.asyncio async def test_serpapi_success_with_empty_results_is_not_an_error(serpapi_http): - serpapi_http.client.search.return_value = { - "search_metadata": {"status": "Success"}, - "search_information": {"organic_results_state": "Fully empty"}, - "error": "Google hasn't returned any results for this query.", - } + serpapi_http.session.get.return_value = _response( + data={ + "search_metadata": {"status": "Success"}, + "search_information": {"organic_results_state": "Fully empty"}, + "error": "Google hasn't returned any results for this query.", + } + ) assert ( await tools._serpapi_search( @@ -713,52 +748,21 @@ async def test_serpapi_success_with_empty_results_is_not_an_error(serpapi_http): @pytest.mark.asyncio -async def test_serpapi_accepts_sdk_result_objects(serpapi_http): - data = { - "search_metadata": {"status": "Success"}, - "news_results": [{"title": "News", "link": "https://example.com/news"}], - } - serpapi_http.client.search.return_value = tools.serpapi.SerpResults( - data, - client=serpapi_http.client, - ) - - result = await tools._serpapi_request( - {"websearch_serpapi_key": ["test-key"]}, - {"q": "OpenAI"}, - engine="google_news", - ) - - assert result == data - assert isinstance(result, dict) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "transport_error", - [ - tools.serpapi.HTTPConnectionError( - requests.ConnectionError( - "Request failed: https://serpapi.com/search.json?api_key=secret-key" - ) - ), - tools.serpapi.TimeoutError( - "Request failed: https://serpapi.com/search.json?api_key=secret-key" - ), - ], -) +@pytest.mark.parametrize("error_type", [aiohttp.ClientError, asyncio.TimeoutError]) async def test_serpapi_transport_errors_do_not_expose_credentials( - serpapi_http, transport_error + serpapi_http, error_type ): - serpapi_http.client.search.side_effect = transport_error + serpapi_http.session.get.side_effect = error_type( + "Request failed: https://serpapi.com/search.json?api_key=secret-key" + ) - with pytest.raises(RuntimeError, match="request failed or timed out") as caught: + with pytest.raises(RuntimeError, match="request failed or timed out") as error: await tools._serpapi_search( {"websearch_serpapi_key": ["secret-key"]}, {"q": "AstrBot"} ) - assert "secret-key" not in str(caught.value) - assert caught.value.__suppress_context__ + assert "secret-key" not in str(error.value) + assert error.value.__suppress_context__ @pytest.mark.asyncio From 21b0d390c1408e8707c293ec6b4b1ce54d4196a2 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:36:41 +0800 Subject: [PATCH 4/4] Update unreleased.md --- changelogs/unreleased.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 4863b132449..d7cb966a8ad 100644 --- a/changelogs/unreleased.md +++ b/changelogs/unreleased.md @@ -2,7 +2,7 @@ - Simplify Context Management Strategy settings: Handling for History Limits or Context Window Pressure → Compression Strategy; Model Provider ID for Context Compression → Compression Model. Move maximum turns, discarded turns, compression instruction, recent-context ratio and fallback context window size under the collapsed More Settings section. Preserve existing configuration keys, defaults and runtime behavior. -- - Use the official SerpApi Python SDK and add Google News search with publisher, author, publication time, and WebUI citation support. +- Add SerpApi Google News search with publisher, author, publication time, and WebUI citation support. - Add compact data-cleanup checkboxes to the existing bot deletion dialog. Custom rules, preferences, configuration routes and scheduled tasks are selected by default; AI conversations, message history, local traces and usage statistics are retained unless selected. Preserve shared resources and match adapter IDs exactly.