init: Search Hub - 统一多搜索引擎聚合服务
This commit is contained in:
350
app.py
Normal file
350
app.py
Normal 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)
|
||||
Reference in New Issue
Block a user