Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions backend_api_python/app/routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'
},
Expand Down Expand Up @@ -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',
Expand Down
70 changes: 67 additions & 3 deletions backend_api_python/app/services/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)"""

Expand Down Expand Up @@ -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)
Expand All @@ -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"},
}
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend_api_python/app/utils/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
2 changes: 2 additions & 0 deletions backend_api_python/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions backend_api_python/tests/test_keenable_search.py
Original file line number Diff line number Diff line change
@@ -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"]