diff --git a/.env.example b/.env.example index 193b782ff..281ff6fbc 100644 --- a/.env.example +++ b/.env.example @@ -842,6 +842,8 @@ SEARCH_GOOGLE_CX= SEARCH_BING_API_KEY= TAVILY_API_KEYS= SERPAPI_KEYS= +# SEARCH_PROVIDER=keenable works without a key; KEENABLE_API_KEY only raises rate limits. +KEENABLE_API_KEY= SEARCH_SEARXNG_BASE_URL= SEARCH_SEARXNG_ENGINES= SEARCH_SEARXNG_CATEGORIES=general diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index 49699044d..6e9d7a707 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -104,7 +104,7 @@ 'LLM_MAX_TOKENS', 'AI_ANALYSIS_CONSENSUS_TIMEFRAMES', 'SEARCH_MAX_RESULTS', 'FAST_ANALYSIS_INCLUDE_GLOBAL_NEWS', - 'SEARCH_GOOGLE_API_KEY', 'SEARCH_GOOGLE_CX', 'SERPAPI_KEYS', + 'SEARCH_GOOGLE_API_KEY', 'SEARCH_GOOGLE_CX', 'SERPAPI_KEYS', 'KEENABLE_API_KEY', 'SEARCH_SEARXNG_ENGINES', 'SEARCH_SEARXNG_CATEGORIES', 'SEARCH_SEARXNG_LANGUAGE', 'SEARCH_SEARXNG_TIMEOUT', 'GDELT_BASE_URL', 'GDELT_TIMEOUT', 'GDELT_MAX_RESULTS', 'ALPHA_VANTAGE_API_KEY', 'ALPHA_VANTAGE_BASE_URL', 'ALPHA_VANTAGE_TIMEOUT', 'ALPHA_VANTAGE_NEWS_LIMIT', @@ -1181,7 +1181,7 @@ 'key': 'SEARCH_PROVIDER', 'label': 'Search Provider', 'type': 'select', - 'options': ['tavily', 'searxng', 'gdelt', 'serpapi', 'google', 'duckduckgo', 'none'], + 'options': ['tavily', 'searxng', 'gdelt', 'serpapi', 'google', 'keenable', 'duckduckgo', 'none'], 'default': 'tavily', 'description': 'Primary news/web search provider used by AI analysis. QuantDinger falls back to configured providers, then GDELT and DuckDuckGo when available' }, @@ -1247,6 +1247,15 @@ 'link_text': 'settings.link.getApiKey', 'description': 'Google Programmable Search Engine ID' }, + { + 'key': 'KEENABLE_API_KEY', + 'label': 'Keenable API Key', + 'type': 'password', + 'required': False, + 'link': 'https://docs.keenable.ai/authentication', + 'link_text': 'settings.link.getApiKey', + 'description': 'Optional. SEARCH_PROVIDER=keenable works without a key; a key only raises the rate limits' + }, { 'key': 'SEARCH_SEARXNG_ENGINES', 'label': 'SearXNG Engines', diff --git a/backend_api_python/app/services/search.py b/backend_api_python/app/services/search.py index 09012929e..6ef25c93f 100644 --- a/backend_api_python/app/services/search.py +++ b/backend_api_python/app/services/search.py @@ -2,7 +2,7 @@ import os import requests import re -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import List, Dict, Any, Optional, Callable from app.services.search_models import BaseSearchProvider, SearchResponse, SearchResult @@ -551,6 +551,61 @@ def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e)) +class KeenableSearchProvider(BaseSearchProvider): + """Keenable web search. Works without an API key; a key only raises rate limits.""" + + BASE_URL = "https://api.keenable.ai/v1" + + def __init__(self, api_key: str = ""): + super().__init__([api_key] if api_key else ['free'], "Keenable") + + def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse: + headers = { + "Content-Type": "application/json", + "X-Keenable-Title": "QuantDinger", + } + if api_key and api_key != "free": + url = f"{self.BASE_URL}/search" + headers["X-API-Key"] = api_key + else: + url = f"{self.BASE_URL}/search/public" + + payload: Dict[str, Any] = {"query": query, "max_results": min(max(max_results, 1), 50)} + if days and days <= 30: + since = datetime.now(timezone.utc).date() - timedelta(days=days) + payload["published_after"] = since.isoformat() + + try: + response = requests.post(url, headers=headers, json=payload, timeout=15) + if response.status_code != 200: + raise RuntimeError(f"HTTP {response.status_code}: {response.text[:200]}") + data = response.json() + items = data.get("results") if isinstance(data, dict) else None + except Exception as e: + return SearchResponse(query=query, results=[], provider=self.name, success=False, error_message=str(e)) + + results = [self._to_result(item) for item in items or [] if isinstance(item, dict) and item.get("url")] + results = results[:max_results] + return SearchResponse( + query=query, + results=results, + provider=self.name, + success=bool(results), + error_message=None if results else "Keenable returned no results", + ) + + def _to_result(self, item: Dict[str, Any]) -> SearchResult: + link = item["url"] + published = item.get("published_at") + return SearchResult( + title=item.get("title") or link, + snippet=" ".join((item.get("snippet") or "").split())[:500], + url=link, + source=self._extract_domain(link), + published_date=published if isinstance(published, str) else "", + ) + + class DuckDuckGoSearchProvider(BaseSearchProvider): """DuckDuckGo 搜索引擎(免费,无需 API Key)""" @@ -712,13 +767,18 @@ def _init_providers(self): provider_map["google"] = GoogleSearchProvider(google_api_key, google_cx) logger.info("Google CSE search is configured") + keenable_api_key = self._search_config_value('keenable', 'api_key', 'KEENABLE_API_KEY') + if self.provider == "keenable" or keenable_api_key: + provider_map["keenable"] = KeenableSearchProvider(keenable_api_key) + logger.info("Keenable search is configured") + provider_map["duckduckgo"] = DuckDuckGoSearchProvider() preferred = self.provider if self.provider in provider_map else "" if preferred: self._providers.append(provider_map.pop(preferred)) - for key in ("tavily", "searxng", "gdelt", "serpapi", "alpha_vantage", "google", "duckduckgo"): + for key in ("tavily", "searxng", "gdelt", "serpapi", "alpha_vantage", "google", "keenable", "duckduckgo"): provider = provider_map.pop(key, None) if provider: self._providers.append(provider) @@ -736,12 +796,14 @@ def provider_status(self) -> List[Dict[str, Any]]: google_api_key = self._search_config_value('google', 'api_key', 'SEARCH_GOOGLE_API_KEY') google_cx = self._search_config_value('google', 'cx', 'SEARCH_GOOGLE_CX') + keenable_api_key = self._search_config_value('keenable', 'api_key', 'KEENABLE_API_KEY') configured = { "Google": bool(google_api_key and google_cx), "Tavily": bool(APIKeys.TAVILY_API_KEYS), "SearXNG": bool(SearXNGConfig.CONFIGURED), "SerpAPI": bool(APIKeys.SERPAPI_KEYS), "AlphaVantage": bool(APIKeys.ALPHA_VANTAGE_API_KEY), + "Keenable": self.provider == "keenable" or bool(keenable_api_key), "GDELT": self.provider not in {"none", "off", "disabled"}, "DuckDuckGo": self.provider not in {"none", "off", "disabled"}, } @@ -755,7 +817,7 @@ def provider_status(self) -> List[Dict[str, Any]]: "available": bool(active_available.get(name)), "note": _search_provider_note(name, bool(configured.get(name)), name in active_names), } - for name in ("Tavily", "SearXNG", "GDELT", "SerpAPI", "AlphaVantage", "Google", "DuckDuckGo") + for name in ("Tavily", "SearXNG", "GDELT", "SerpAPI", "AlphaVantage", "Google", "Keenable", "DuckDuckGo") ] def _effective_result_limit(self, requested: Optional[int]) -> int: @@ -957,6 +1019,8 @@ def _search_provider_note(name: str, configured: bool, registered: bool) -> str: return "Set SERPAPI_KEYS to enable SerpAPI Google/Bing search." if name == "AlphaVantage" and not configured: return "Set ALPHA_VANTAGE_API_KEY to enable company news and sentiment." + if name == "Keenable" and not configured: + return "Set SEARCH_PROVIDER=keenable for keyless Keenable web search; KEENABLE_API_KEY raises rate limits." if name == "GDELT" and configured: return "Free global news fallback, no API key required." if configured and not registered: diff --git a/backend_api_python/app/utils/config_loader.py b/backend_api_python/app/utils/config_loader.py index 391c66f83..d23365783 100644 --- a/backend_api_python/app/utils/config_loader.py +++ b/backend_api_python/app/utils/config_loader.py @@ -201,6 +201,7 @@ def env_get(name: str) -> Optional[str]: ('SEARCH_SEARXNG_CATEGORIES', 'search.searxng.categories', 'string'), ('SEARCH_SEARXNG_LANGUAGE', 'search.searxng.language', 'string'), ('SEARCH_SEARXNG_TIMEOUT', 'search.searxng.timeout', 'int'), + ('KEENABLE_API_KEY', 'search.keenable.api_key', 'string'), # Tavily (AI-optimized search) ('TAVILY_API_KEYS', 'tavily.api_keys', 'string'), diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 4f7f44358..9a233f838 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -716,6 +716,8 @@ SEARCH_GOOGLE_API_KEY= SEARCH_GOOGLE_CX= TAVILY_API_KEYS= SERPAPI_KEYS= +# SEARCH_PROVIDER=keenable works without a key; KEENABLE_API_KEY only raises rate limits. +KEENABLE_API_KEY= SEARCH_SEARXNG_BASE_URL= SEARCH_SEARXNG_ENGINES= SEARCH_SEARXNG_CATEGORIES=general diff --git a/backend_api_python/tests/test_keenable_search.py b/backend_api_python/tests/test_keenable_search.py new file mode 100644 index 000000000..71e980d97 --- /dev/null +++ b/backend_api_python/tests/test_keenable_search.py @@ -0,0 +1,115 @@ +"""Keenable search provider: keyless request shape, optional key, and opt-in registration.""" +from types import SimpleNamespace + +import pytest + +from app.services import search as service +from app.utils import config_loader + + +SEARCH_ENV = ( + "SEARCH_PROVIDER", "KEENABLE_API_KEY", "TAVILY_API_KEYS", "SERPAPI_KEYS", "ALPHA_VANTAGE_API_KEY", + "SEARCH_GOOGLE_API_KEY", "SEARCH_GOOGLE_CX", "SEARCH_BING_API_KEY", "SEARCH_SEARXNG_BASE_URL", +) + + +def _response(status=200, payload=None, text=""): + return SimpleNamespace(status_code=status, json=lambda: payload or {}, text=text) + + +def _capture_post(monkeypatch, response): + calls = [] + + def post(url, **kwargs): + calls.append({"url": url, **kwargs}) + return response + + monkeypatch.setattr(service.requests, "post", post) + return calls + + +def _service(monkeypatch, search_config=None, env=None): + for name in SEARCH_ENV: + monkeypatch.delenv(name, raising=False) + for name, value in (env or {}).items(): + monkeypatch.setenv(name, value) + config = {"search": search_config or {}} + monkeypatch.setattr(service, "load_addon_config", lambda: config) + monkeypatch.setattr(config_loader, "load_addon_config", lambda: config) + return service.SearchService() + + +def test_keyless_search_uses_public_endpoint_and_maps_snippet(monkeypatch): + calls = _capture_post(monkeypatch, _response(payload={"results": [ + {"title": "NVDA earnings", "url": "https://www.example.com/nvda", "description": "", + "snippet": "Nvidia reported\n\nrecord revenue " + "x" * 600, "published_at": "2026-11-02T10:00:00Z"}, + {"title": "No URL", "url": "", "snippet": "dropped"}, + {"title": "Second", "url": "https://news.example.org/a", "snippet": "Second result"}, + ]})) + + result = service.KeenableSearchProvider().search("NVDA earnings", max_results=5, days=3) + + assert result.success + assert calls[0]["url"] == "https://api.keenable.ai/v1/search/public" + assert calls[0]["headers"]["X-Keenable-Title"] == "QuantDinger" + assert "X-API-Key" not in calls[0]["headers"] + assert calls[0]["json"]["query"] == "NVDA earnings" + assert calls[0]["json"]["max_results"] == 5 + assert len(calls[0]["json"]["published_after"]) == 10 + first = result.results[0] + assert first.snippet.startswith("Nvidia reported record revenue") + assert len(first.snippet) == 500 + assert first.source == "example.com" + assert first.published_date == "2026-11-02T10:00:00Z" + assert [item.url for item in result.results] == ["https://www.example.com/nvda", "https://news.example.org/a"] + + +def test_api_key_uses_keyed_endpoint_and_long_windows_skip_date_filter(monkeypatch): + calls = _capture_post(monkeypatch, _response(payload={"results": [ + {"title": "10-K", "url": "https://www.sec.gov/report.htm", "snippet": "Annual report"}, + ]})) + + result = service.KeenableSearchProvider("keen_test").search("Apple 10-K", max_results=80, days=3650) + + assert result.success + assert calls[0]["url"] == "https://api.keenable.ai/v1/search" + assert calls[0]["headers"]["X-API-Key"] == "keen_test" + assert calls[0]["json"]["max_results"] == 50 + assert "published_after" not in calls[0]["json"] + + +@pytest.mark.parametrize("response", [ + _response(status=429, text="Too Many Requests"), + _response(payload={"results": []}), +]) +def test_errors_and_empty_results_let_the_chain_fall_through(monkeypatch, response): + _capture_post(monkeypatch, response) + result = service.KeenableSearchProvider().search("query", max_results=5) + assert not result.success + assert result.results == [] + + +def test_keenable_is_not_registered_unless_selected(monkeypatch): + search = _service(monkeypatch) + + names = [provider.name for provider in search._providers] + assert "Keenable" not in names + assert names == ["GDELT", "DuckDuckGo"] + status = {row["provider"]: row for row in search.provider_status()} + assert status["Keenable"]["configured"] is False + assert "SEARCH_PROVIDER=keenable" in status["Keenable"]["note"] + + +def test_selecting_keenable_puts_it_first_without_reordering_the_rest(monkeypatch): + search = _service(monkeypatch, search_config={"provider": "keenable"}) + + assert [provider.name for provider in search._providers] == ["Keenable", "GDELT", "DuckDuckGo"] + status = {row["provider"]: row for row in search.provider_status()} + assert status["Keenable"]["configured"] is True + assert status["Keenable"]["available"] is True + + +def test_api_key_alone_registers_keenable_in_the_fallback_chain(monkeypatch): + search = _service(monkeypatch, env={"KEENABLE_API_KEY": "keen_test", "TAVILY_API_KEYS": "tvly-test"}) + + assert [provider.name for provider in search._providers] == ["Tavily", "GDELT", "Keenable", "DuckDuckGo"]