init: Search Hub - 统一多搜索引擎聚合服务

This commit is contained in:
2026-05-09 18:46:05 +08:00
commit 81d726179c
27 changed files with 3179 additions and 0 deletions

350
app.py Normal file
View File

@@ -0,0 +1,350 @@
"""Search Hub — 统一搜索服务 + Web UI + Admin"""
import os
from flask import (
Flask, request, jsonify, render_template,
Response, stream_with_context,
)
from config import load_config
from hub.router import SearchRouter
from hub.config_manager import (
get_source_config, update_source_config,
get_source_schema, mask_key,
)
from hub.quota import check_tavily_usage, check_baidu_vdb_quota
from providers.tavily_provider import TavilyProvider
from providers.duckduckgo_provider import DuckDuckGoProvider
from providers.baidu_provider import BaiduProvider
from providers.ai_provider import AIProvider
from providers.searxng_provider import SearXNGProvider
app = Flask(__name__)
# 初始化搜索路由器
_raw_config = load_config()
_providers = {
'tavily': TavilyProvider(_raw_config),
'duckduckgo': DuckDuckGoProvider(_raw_config),
'baidu': BaiduProvider(_raw_config, mode='web'),
'baidu-intelligent': BaiduProvider(_raw_config, mode='intelligent'),
'ai': AIProvider(_raw_config),
'searxng': SearXNGProvider(_raw_config),
}
router = SearchRouter(_providers)
# 搜索历史
search_history = []
def _reload_providers():
"""重新加载所有 provider 配置(管理面板修改密钥后调用)"""
global _raw_config, _providers, router
_raw_config = load_config()
_providers = {
'tavily': TavilyProvider(_raw_config),
'duckduckgo': DuckDuckGoProvider(_raw_config),
'baidu': BaiduProvider(_raw_config, mode='web'),
'baidu-intelligent': BaiduProvider(_raw_config, mode='intelligent'),
'ai': AIProvider(_raw_config),
'searxng': SearXNGProvider(_raw_config),
}
router = SearchRouter(_providers)
# ===================== 统一搜索 API =====================
@app.route('/api/search', methods=['POST'])
def api_search():
"""统一搜索接口"""
data = request.get_json()
query = (data.get('query') or '').strip()
source = (data.get('source') or 'auto').strip()
max_results = int(data.get('max_results', 10))
if not query:
return jsonify({'error': '请输入搜索词'}), 400
result = router.search(query, source=source, max_results=max_results)
# 记录历史
if not result.get('error'):
if query in search_history:
search_history.remove(query)
search_history.insert(0, query)
if len(search_history) > 20:
search_history.pop()
return jsonify(result)
@app.route('/api/sources', methods=['GET'])
def api_sources():
"""查询可用搜索源"""
return jsonify({
'sources': router.get_sources(),
})
@app.route('/api/status', methods=['GET'])
def api_status():
"""健康检查 + 统计"""
sources = router.get_sources()
available = [s['name'] for s in sources if s['available']]
return jsonify({
'status': 'ok',
'version': '1.0',
'available_sources': available,
'history_count': len(search_history),
})
@app.route('/api/history', methods=['GET'])
def api_history():
return jsonify({'history': search_history})
# ===================== 管理 API =====================
@app.route('/api/admin/sources/<name>', methods=['GET'])
def admin_get_source(name):
"""获取单个源的可配置字段(密钥脱敏)"""
provider = _providers.get(name)
if not provider:
return jsonify({'error': f'搜索源 "{name}" 不存在'}), 404
schema = get_source_schema(name)
local_cfg = get_source_config(name)
# 处理字段名到配置 key 的映射
config_key = name
if name == 'ai':
config_key = 'opencodezen'
elif name == 'baidu-intelligent':
config_key = 'baidu' # 共享百度配置
hermes_cfg = _raw_config.get(config_key, {})
fields = []
for field in schema:
key = field['key']
# 优先取本地配置,否则取 Hermes 配置
raw_value = local_cfg.get(key) or hermes_cfg.get(key) or field.get('default', '')
fields.append({
'key': key,
'label': field['label'],
'type': field['type'],
'required': field.get('required', False),
'value': mask_key(raw_value) if field['type'] == 'password' else raw_value,
'has_value': bool(raw_value),
})
return jsonify({
'name': name,
'display_name': provider.display_name,
'available': provider.is_available(),
'fields': fields,
})
@app.route('/api/admin/sources/<name>', methods=['POST'])
def admin_update_source(name):
"""更新某个源的配置"""
provider = _providers.get(name)
if not provider:
return jsonify({'error': f'搜索源 "{name}" 不存在'}), 404
data = request.get_json()
if not data:
return jsonify({'error': '请提供配置数据'}), 400
# 获取该源的可配字段定义
schema = get_source_schema(name)
allowed_keys = {f['key'] for f in schema}
# 过滤出允许的字段
updates = {k: v for k, v in data.items() if k in allowed_keys}
if not updates:
return jsonify({'error': '没有可更新的配置字段'}), 400
# 保存到本地配置
for key, value in updates.items():
update_source_config(name, {key: value})
# 重新加载 providers
_reload_providers()
return jsonify({
'success': True,
'message': f'{provider.display_name} 配置已更新',
'updated': list(updates.keys()),
})
@app.route('/api/admin/usage', methods=['GET'])
def admin_usage():
"""查询各服务剩余额度(异步调用,前端不阻塞)"""
tavily_cfg = get_source_config('tavily') or {}
tavily_api_key = tavily_cfg.get('api_key') or _raw_config.get('tavily', {}).get('api_key')
baidu_cfg = get_source_config('baidu') or {}
vdb_ak = baidu_cfg.get('vdb_access_key') or ''
vdb_sk = baidu_cfg.get('vdb_secret_key') or ''
# 后端异步执行,不阻塞 Flask 进程
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as pool:
tavily_fut = pool.submit(check_tavily_usage, tavily_api_key)
baidu_fut = pool.submit(check_baidu_vdb_quota, vdb_ak, vdb_sk)
result = {
'tavily': tavily_fut.result(timeout=15),
'baidu_vdb': baidu_fut.result(timeout=15),
}
return jsonify(result)
@app.route('/api/ai-summarize', methods=['POST'])
def api_ai_summarize():
"""非流式 AI 总结"""
data = request.get_json()
query = (data.get('query') or '').strip()
results = data.get('results', [])
if not query:
return jsonify({'error': '请输入搜索词'}), 400
if not results:
return jsonify({'error': '没有搜索结果可总结'}), 400
ai = _providers.get('ai')
if not ai or not ai.is_available():
return jsonify({'error': 'AI 总结未配置', 'summary': ''}), 400
result = ai.summarize(query, results)
return jsonify(result)
@app.route('/api/ai-summarize/stream', methods=['POST'])
def api_ai_summarize_stream():
"""流式 AI 总结SSE"""
data = request.get_json()
query = (data.get('query') or '').strip()
results = data.get('results', [])
if not query:
return jsonify({'error': '请输入搜索词'}), 400
if not results:
return jsonify({'error': '没有搜索结果可总结'}), 400
ai = _providers.get('ai')
if not ai or not ai.is_available():
return jsonify({'error': 'AI 总结未配置'}), 400
return Response(
stream_with_context(ai.summarize_stream(query, results)),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive',
},
)
# ===================== API 文档 =====================
API_DOCS = [
{
'method': 'POST',
'path': '/api/search',
'desc': '统一搜索',
'body': {
'query': '搜索词',
'source': 'auto/tavily/baidu/searxng/baidu-intelligent 或逗号组合',
'max_results': 10,
},
'example': "curl -X POST http://localhost:8650/api/search \\\n -H 'Content-Type: application/json' \\\n -d '{\"query\":\"深度学习\",\"source\":\"auto\",\"max_results\":5}'",
},
{
'method': 'GET',
'path': '/api/sources',
'desc': '查看可用搜索源',
'example': 'curl http://localhost:8650/api/sources',
},
{
'method': 'GET',
'path': '/api/status',
'desc': '服务状态',
'example': 'curl http://localhost:8650/api/status',
},
{
'method': 'GET',
'path': '/api/history',
'desc': '搜索历史',
'example': 'curl http://localhost:8650/api/history',
},
{
'method': 'POST',
'path': '/api/ai-summarize',
'desc': 'AI 总结(非流式)',
'body': {
'query': '搜索词',
'results': [{'title': '', 'url': '', 'content': ''}],
},
'example': "curl -X POST http://localhost:8650/api/ai-summarize \\\n -H 'Content-Type: application/json' \\\n -d '{\"query\":\"test\",\"results\":[{\"title\":\"T\",\"url\":\"https://x.com\",\"content\":\"C\"}]}'",
},
{
'method': 'POST',
'path': '/api/ai-summarize/stream',
'desc': 'AI 总结(流式 SSE',
'body': {
'query': '搜索词',
'results': [{'title': '', 'url': '', 'content': ''}],
},
'example': "curl -N -X POST http://localhost:8650/api/ai-summarize/stream \\\n -H 'Content-Type: application/json' \\\n -d '{\"query\":\"test\",\"results\":[{\"title\":\"T\",\"url\":\"https://x.com\",\"content\":\"C\"}]}'",
},
{
'method': 'GET',
'path': '/api/admin/sources/{name}',
'desc': '获取搜索源配置(字段定义)',
'example': "curl http://localhost:8650/api/admin/sources/tavily",
},
{
'method': 'POST',
'path': '/api/admin/sources/{name}',
'desc': '更新搜索源配置',
'example': "curl -X POST http://localhost:8650/api/admin/sources/tavily \\\n -H 'Content-Type: application/json' \\\n -d '{\"api_key\":\"sk-xxx\"}'",
},
]
@app.route('/api/docs')
def api_docs():
return jsonify({'endpoints': API_DOCS})
@app.route('/api')
def api_index():
"""API 文档入口"""
return jsonify({
'name': 'Search Hub API',
'version': '1.0',
'endpoints': '/api/docs',
'web_ui': '/web',
})
# ===================== 页面路由 =====================
@app.route('/')
@app.route('/web')
def web_index():
"""Web 搜索界面"""
sources = router.get_sources()
return render_template('index.html', sources=sources)
@app.route('/admin')
def admin_index():
"""管理面板"""
sources = router.get_sources()
return render_template('admin.html', sources=sources)
# ===================== 启动 =====================
if __name__ == '__main__':
port = int(os.environ.get('PORT', 8650))
app.run(host='0.0.0.0', port=port, debug=False)