mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
feat: 自建 SearXNG 搜索超时可配置(SEARXNG_TIMEOUT_SECONDS) (#2249)
* feat: make self-hosted SearXNG search timeout configurable SEARXNG_TIMEOUT_SECONDS (default 10s, min 1) now controls the per-search timeout for self-hosted SearXNG instances, threaded through all three SearchService construction sites. Public-instance timeout stays fixed. * fix: thread SEARXNG_TIMEOUT_SECONDS through subprocess rebuild and Actions - persist searxng_timeout_seconds in _constructor_kwargs so the bounded topic-news subprocess rebuild no longer silently falls back to 10s - map SEARXNG_TIMEOUT_SECONDS (vars||secrets) in 00-daily-analysis.yml - document the variable in full-guide/full-guide_EN data-source tables, add CHANGELOG entry, and add regression tests for both paths * fix: document SearXNG timeout environment contract * fix: register SearXNG timeout setting * fix: keep SearXNG timeout env-only --------- Co-authored-by: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com> Co-authored-by: zhulinsen <zhuls97@163.com>
This commit is contained in:
@@ -402,6 +402,8 @@ BRAVE_API_KEYS=
|
||||
# 因此默认关闭。推荐配置自建实例地址(SEARXNG_BASE_URLS),或使用上方任一带 key 的搜索渠道。
|
||||
SEARXNG_BASE_URLS=
|
||||
SEARXNG_PUBLIC_INSTANCES_ENABLED=false
|
||||
# 自建 SearXNG 单次搜索超时(秒,默认 10,最小 1);公共实例不受此配置影响
|
||||
SEARXNG_TIMEOUT_SECONDS=10
|
||||
|
||||
# ===================================
|
||||
# Social Sentiment Intelligence (US stocks only)
|
||||
|
||||
1
.github/workflows/00-daily-analysis.yml
vendored
1
.github/workflows/00-daily-analysis.yml
vendored
@@ -309,6 +309,7 @@ jobs:
|
||||
BRAVE_API_KEYS: ${{ secrets.BRAVE_API_KEYS }}
|
||||
SEARXNG_BASE_URLS: ${{ vars.SEARXNG_BASE_URLS || secrets.SEARXNG_BASE_URLS }}
|
||||
SEARXNG_PUBLIC_INSTANCES_ENABLED: ${{ vars.SEARXNG_PUBLIC_INSTANCES_ENABLED || secrets.SEARXNG_PUBLIC_INSTANCES_ENABLED }}
|
||||
SEARXNG_TIMEOUT_SECONDS: ${{ vars.SEARXNG_TIMEOUT_SECONDS || secrets.SEARXNG_TIMEOUT_SECONDS }}
|
||||
|
||||
# ==========================================
|
||||
# 通知渠道(可同时配置多个,全部推送)
|
||||
|
||||
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
> For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page.
|
||||
|
||||
## [Unreleased]
|
||||
- [新功能] 新增 `SEARXNG_TIMEOUT_SECONDS` 配置自建 SearXNG 单次搜索超时(默认 10 秒),已接线全部 SearchService 构造入口(含题材搜索子进程重建)与默认 GitHub Actions 工作流
|
||||
|
||||
- [修复] 美股日线路由现按各数据源当前优先级排序,单项 `*_PRIORITY` 配置(如 `YFINANCE_PRIORITY=0`)对美股即时生效;指数固定首选与 Longbridge preferred 语义保持不变
|
||||
|
||||
- [新功能] 新增个股研究聚合 API,以统一 canonical code 返回行情、历史、研究产物、资讯、缓存持仓关系和监控规则,并对每个块独立标记 fresh/partial/unavailable。
|
||||
|
||||
@@ -165,6 +165,7 @@ daily_stock_analysis/
|
||||
| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search(结构化搜索结果) | 可选 |
|
||||
| `SEARXNG_BASE_URLS` | SearXNG 自建实例(无配额兜底,需在 settings.yml 启用 format: json);留空时仅在显式启用公共实例发现后使用 `searx.space` | 可选 |
|
||||
| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | 是否在 `SEARXNG_BASE_URLS` 为空时自动从 `searx.space` 获取公共实例(默认 `false`)。公共实例普遍限流或未开启 JSON 输出,开启后每次分析可能多耗 30~60 秒且新闻面为空 | 可选 |
|
||||
| `SEARXNG_TIMEOUT_SECONDS` | 自建 SearXNG 单次搜索超时(秒,默认 `10`,最小 `1`);实例较慢(如聚合引擎较多的 NAS 部署)时可调大,公共实例超时不受影响。GitHub Actions 需显式映射该变量 | 可选 |
|
||||
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638 ) Token | 可选 |
|
||||
| `TUSHARE_HTTP_URL` | Tushare Pro HTTP 接入地址;留空(或未设置/空白)时使用官方端点 `http://api.tushare.pro`,仅在需通过公司内网代理、跨境网络或自建镜像时填写 `http://` 或 `https://` 开头的完整地址 | 可选 |
|
||||
| `TICKFLOW_API_KEY` | [TickFlow](https://tickflow.org) API Key;可选,用于 A 股日 K、实时行情、股票列表/名称与大盘复盘增强;失败或权限不足时自动回退。 | 可选 |
|
||||
@@ -375,6 +376,7 @@ daily_stock_analysis/
|
||||
| `SOCIAL_SENTIMENT_API_URL` | Stock Sentiment API 地址(默认 `https://api.adanos.org`) | 可选 |
|
||||
| `SEARXNG_BASE_URLS` | SearXNG 自建实例(无配额兜底,需在 settings.yml 启用 format: json);留空时仅在显式启用公共实例发现后使用 `searx.space` | 可选 |
|
||||
| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | 是否在 `SEARXNG_BASE_URLS` 为空时自动从 `searx.space` 获取公共实例(默认 `false`)。公共实例普遍限流或未开启 JSON 输出,开启后每次分析可能多耗 30~60 秒且新闻面为空 | 可选 |
|
||||
| `SEARXNG_TIMEOUT_SECONDS` | 自建 SearXNG 单次搜索超时(秒,默认 `10`,最小 `1`);实例较慢(如聚合引擎较多的 NAS 部署)时可调大,公共实例超时不受影响。GitHub Actions 需显式映射该变量 | 可选 |
|
||||
| `NEWS_STRATEGY_PROFILE` | 新闻策略窗口档位:`ultra_short`(1天)/`short`(3天)/`medium`(7天)/`long`(30天);实际窗口取与 `NEWS_MAX_AGE_DAYS` 的最小值 | 默认 `short` |
|
||||
| `NEWS_MAX_AGE_DAYS` | 新闻最大时效(天),搜索时限制结果在近期内 | 默认 `3` |
|
||||
| `BIAS_THRESHOLD` | 乖离率阈值(%),超过提示不追高;强势趋势股自动放宽到 1.5 倍 | 默认 `5.0` |
|
||||
|
||||
@@ -158,6 +158,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
|
||||
| `MINIMAX_API_KEYS` | [MiniMax](https://platform.minimax.io/) Coding Plan Web Search (structured search results) | Optional |
|
||||
| `SEARXNG_BASE_URLS` | SearXNG self-hosted instances (quota-free fallback, enable format: json in settings.yml); when empty, `searx.space` discovery is used only if public instances are explicitly enabled | Optional |
|
||||
| `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `false`). Public instances are commonly rate-limited or do not return JSON, so enabling this can add 30-60s per run and still yield no news | Optional |
|
||||
| `SEARXNG_TIMEOUT_SECONDS` | Per-search timeout in seconds for self-hosted SearXNG instances (default `10`, minimum `1`). Increase for slow instances (e.g. NAS deployments aggregating many engines); public-instance timeout is unaffected. GitHub Actions requires explicit variable mapping | Optional |
|
||||
| `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional |
|
||||
| `TUSHARE_HTTP_URL` | Tushare Pro HTTP endpoint; when unset/empty defaults to the official `http://api.tushare.pro`. Set to a `http://` or `https://` URL only when routing through a corporate proxy, cross-border network, or a self-hosted mirror | Optional |
|
||||
| `TICKFLOW_API_KEY` | [TickFlow](https://tickflow.org) API key for optional A-share daily K-lines, realtime quotes, stock list/name lookup, and CN market review enhancement; permission or entitlement failures fall back to existing providers | Optional |
|
||||
|
||||
@@ -983,6 +983,7 @@ class Config:
|
||||
serpapi_keys: List[str] = field(default_factory=list) # SerpAPI Keys
|
||||
searxng_base_urls: List[str] = field(default_factory=list) # SearXNG instance URLs (self-hosted, no quota)
|
||||
searxng_public_instances_enabled: bool = False # Opt in to public discovery when base URLs are absent
|
||||
searxng_timeout_seconds: int = 10 # 自建 SearXNG 单次搜索超时(秒)
|
||||
|
||||
# === Social Sentiment (US stocks only, api.adanos.org) ===
|
||||
social_sentiment_api_key: Optional[str] = None
|
||||
@@ -1880,6 +1881,9 @@ class Config:
|
||||
serpapi_keys=serpapi_keys,
|
||||
searxng_base_urls=searxng_base_urls,
|
||||
searxng_public_instances_enabled=searxng_public_instances_enabled,
|
||||
searxng_timeout_seconds=parse_env_int(
|
||||
os.getenv('SEARXNG_TIMEOUT_SECONDS'), 10, field_name='SEARXNG_TIMEOUT_SECONDS', minimum=1
|
||||
),
|
||||
social_sentiment_api_key=os.getenv('SOCIAL_SENTIMENT_API_KEY') or None,
|
||||
social_sentiment_api_url=os.getenv('SOCIAL_SENTIMENT_API_URL', 'https://api.adanos.org').rstrip('/'),
|
||||
news_max_age_days=parse_env_int(os.getenv('NEWS_MAX_AGE_DAYS'), 3, field_name='NEWS_MAX_AGE_DAYS', minimum=1),
|
||||
|
||||
@@ -79,6 +79,7 @@ WEB_SETTINGS_HIDDEN_FROM_UI = {
|
||||
"USE_PROXY",
|
||||
"PROXY_HOST",
|
||||
"PROXY_PORT",
|
||||
"SEARXNG_TIMEOUT_SECONDS",
|
||||
}
|
||||
|
||||
_FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
|
||||
@@ -105,6 +105,7 @@ def build_market_review_runtime(
|
||||
"searxng_public_instances_enabled",
|
||||
False,
|
||||
),
|
||||
searxng_timeout_seconds=getattr(config, "searxng_timeout_seconds", None),
|
||||
news_max_age_days=getattr(config, "news_max_age_days", 3),
|
||||
news_strategy_profile=getattr(config, "news_strategy_profile", "short"),
|
||||
)
|
||||
|
||||
@@ -309,6 +309,7 @@ class StockAnalysisPipeline:
|
||||
minimax_keys=self.config.minimax_api_keys,
|
||||
searxng_base_urls=self.config.searxng_base_urls,
|
||||
searxng_public_instances_enabled=self.config.searxng_public_instances_enabled,
|
||||
searxng_timeout_seconds=getattr(self.config, "searxng_timeout_seconds", None),
|
||||
news_max_age_days=self.config.news_max_age_days,
|
||||
news_strategy_profile=getattr(self.config, "news_strategy_profile", "short"),
|
||||
)
|
||||
|
||||
@@ -1843,10 +1843,20 @@ class SearXNGSearchProvider(BaseSearchProvider):
|
||||
_public_instances_stale_retry_after: float = 0.0
|
||||
_public_instances_lock = threading.Lock()
|
||||
|
||||
def __init__(self, base_urls: Optional[List[str]] = None, *, use_public_instances: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
base_urls: Optional[List[str]] = None,
|
||||
*,
|
||||
use_public_instances: bool = False,
|
||||
self_hosted_timeout_seconds: Optional[int] = None,
|
||||
):
|
||||
normalized_base_urls = [url.rstrip("/") for url in (base_urls or []) if url.strip()]
|
||||
super().__init__(normalized_base_urls, "SearXNG")
|
||||
self._base_urls = normalized_base_urls
|
||||
if self_hosted_timeout_seconds and int(self_hosted_timeout_seconds) > 0:
|
||||
self._self_hosted_timeout_seconds = int(self_hosted_timeout_seconds)
|
||||
else:
|
||||
self._self_hosted_timeout_seconds = self.SELF_HOSTED_TIMEOUT_SECONDS
|
||||
self._use_public_instances = bool(use_public_instances and not self._base_urls)
|
||||
self._cursor = 0
|
||||
self._cursor_lock = threading.Lock()
|
||||
@@ -2157,7 +2167,7 @@ class SearXNGSearchProvider(BaseSearchProvider):
|
||||
max_attempts=len(self._base_urls),
|
||||
)
|
||||
retry_enabled = True
|
||||
timeout = self.SELF_HOSTED_TIMEOUT_SECONDS
|
||||
timeout = self._self_hosted_timeout_seconds
|
||||
empty_error = "SearXNG 未配置可用实例"
|
||||
elif self._use_public_instances:
|
||||
public_instances = self._get_public_instances()
|
||||
@@ -2390,6 +2400,7 @@ class SearchService:
|
||||
minimax_keys: Optional[List[str]] = None,
|
||||
searxng_base_urls: Optional[List[str]] = None,
|
||||
searxng_public_instances_enabled: bool = False,
|
||||
searxng_timeout_seconds: Optional[int] = None,
|
||||
news_max_age_days: int = 3,
|
||||
news_strategy_profile: str = "short",
|
||||
):
|
||||
@@ -2417,6 +2428,7 @@ class SearchService:
|
||||
"minimax_keys": list(minimax_keys or []),
|
||||
"searxng_base_urls": list(searxng_base_urls or []),
|
||||
"searxng_public_instances_enabled": bool(searxng_public_instances_enabled),
|
||||
"searxng_timeout_seconds": searxng_timeout_seconds,
|
||||
"news_max_age_days": int(news_max_age_days),
|
||||
"news_strategy_profile": news_strategy_profile,
|
||||
}
|
||||
@@ -2468,6 +2480,7 @@ class SearchService:
|
||||
searxng_provider = SearXNGSearchProvider(
|
||||
searxng_base_urls,
|
||||
use_public_instances=bool(searxng_public_instances_enabled and not searxng_base_urls),
|
||||
self_hosted_timeout_seconds=searxng_timeout_seconds,
|
||||
)
|
||||
if searxng_provider.is_available:
|
||||
self._providers.append(searxng_provider)
|
||||
@@ -4890,6 +4903,7 @@ def get_search_service() -> SearchService:
|
||||
minimax_keys=config.minimax_api_keys,
|
||||
searxng_base_urls=config.searxng_base_urls,
|
||||
searxng_public_instances_enabled=config.searxng_public_instances_enabled,
|
||||
searxng_timeout_seconds=getattr(config, "searxng_timeout_seconds", None),
|
||||
news_max_age_days=config.news_max_age_days,
|
||||
news_strategy_profile=getattr(config, "news_strategy_profile", "short"),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,16 @@ def test_env_example_defaults_public_searxng_instances_off() -> None:
|
||||
assert "SEARXNG_PUBLIC_INSTANCES_ENABLED=true" not in env_example
|
||||
|
||||
|
||||
def test_searxng_timeout_env_contract() -> None:
|
||||
from src.config import parse_env_int
|
||||
|
||||
env_example = (ROOT_DIR / ".env.example").read_text(encoding="utf-8")
|
||||
assert "SEARXNG_TIMEOUT_SECONDS=10" in env_example
|
||||
assert parse_env_int(None, 10, field_name="SEARXNG_TIMEOUT_SECONDS", minimum=1) == 10
|
||||
assert parse_env_int("25", 10, field_name="SEARXNG_TIMEOUT_SECONDS", minimum=1) == 25
|
||||
assert parse_env_int("0", 10, field_name="SEARXNG_TIMEOUT_SECONDS", minimum=1) == 1
|
||||
|
||||
|
||||
def test_daily_analysis_workflow_matches_documented_searxng_variable_mapping() -> None:
|
||||
workflow = (
|
||||
ROOT_DIR / ".github" / "workflows" / "00-daily-analysis.yml"
|
||||
@@ -131,3 +141,31 @@ def test_docs_describe_public_searxng_discovery_as_opt_in() -> None:
|
||||
for line in english_base_url_lines
|
||||
)
|
||||
assert all("default `false`" in line for line in english_public_toggle_lines)
|
||||
|
||||
|
||||
def test_constructor_kwargs_carry_searxng_timeout_for_subprocess_rebuild() -> None:
|
||||
"""bounded 题材搜索在子进程中重建 SearchService 时必须带上超时配置(PR #2249 blocker)。"""
|
||||
from src.search_service import SearchService
|
||||
|
||||
service = SearchService(
|
||||
searxng_base_urls=["http://searxng.local:8080"],
|
||||
searxng_timeout_seconds=25,
|
||||
)
|
||||
kwargs = service._constructor_kwargs
|
||||
assert kwargs["searxng_timeout_seconds"] == 25
|
||||
assert kwargs["searxng_base_urls"] == ["http://searxng.local:8080"]
|
||||
|
||||
# 子进程按同一 kwargs 重建后,provider 应拿到相同超时
|
||||
rebuilt = SearchService(**kwargs)
|
||||
searxng = next(p for p in rebuilt._providers if p.name == "SearXNG")
|
||||
assert searxng._self_hosted_timeout_seconds == 25
|
||||
|
||||
|
||||
def test_daily_analysis_workflow_maps_searxng_timeout_variable() -> None:
|
||||
workflow = (
|
||||
ROOT_DIR / ".github" / "workflows" / "00-daily-analysis.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
assert (
|
||||
"SEARXNG_TIMEOUT_SECONDS: ${{ vars.SEARXNG_TIMEOUT_SECONDS || secrets.SEARXNG_TIMEOUT_SECONDS }}"
|
||||
in workflow
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user