Files
yu-zhi-ran/scripts/search_utils.py
T
Yuzhiran Dev 499c511140 chore: opencode冗余清理 + LLM任务级模型选择 + systemd服务化
- 删除 opencode_search.py / mcp_search_server.py 及所有 MCP 引用
- 移除搜索缓存定时任务(scheduled_refresh_search_cache)
- 清理前后端所有 opencode/MCP 代码和注释
- LLM 提供商量换:opencode-go→nvidia(默认)+sensenova(合规审查)
- llm_configs 新增 is_default 字段,API 层互斥逻辑
- 所有定时任务支持独立 LLM 模型选择(LLM_TASK_PROVIDER env)
- compliance_optimizer.py 修复:import os / 解硬编码 / 关键词过滤
- Scheduler 日志修复:始终 INSERT,避免僵尸 running 行
- Systemd 服务化:Restart=always / 单 worker / Type=exec
- 搜索提供商:替换 opencode→360/搜狗/微信(免 Key)
- 更新 AGENTS.md / PROGRESS.md
2026-06-02 15:38:16 +08:00

369 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
统一搜索工具:DB驱动多提供商自动降级
"""
import json, logging, os, sys, time
from pathlib import Path
from typing import List, Dict, Optional
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
try:
from app.database import SessionLocal
from app.models import SearchProvider
HAVE_DB = True
except ImportError:
HAVE_DB = False
SEARCH_CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "search_cache.json"
def _get_providers() -> List[Dict]:
"""从 DB 加载启用的搜索提供商,按优先级排序(自动跨日重置用量)"""
if not HAVE_DB:
return []
try:
import datetime as _dt
db = SessionLocal()
today = _dt.date.today()
rows = db.query(SearchProvider).filter(
SearchProvider.enabled == True
).order_by(SearchProvider.priority).all()
needs_commit = False
for r in rows:
if (r.usage_today or 0) > 0 and r.last_used_at:
last_date = r.last_used_at
if hasattr(last_date, 'date'):
last_date = last_date.date()
elif isinstance(last_date, _dt.datetime):
last_date = last_date.date()
if last_date < today:
r.usage_today = 0
needs_commit = True
if needs_commit:
db.commit()
db.close()
return [r.to_dict() if hasattr(r, 'to_dict') else {
"id": r.id, "name": r.name, "provider_type": r.provider_type,
"api_key": r.api_key or "", "api_url": r.api_url or "",
"priority": r.priority, "daily_limit": r.daily_limit,
"usage_today": getattr(r, 'usage_today', 0),
} for r in rows]
except Exception as e:
logger.warning("加载搜索提供商失败: %s", e)
return []
def reset_all_usage():
"""手动重置所有提供商当日用量(供 API/定时任务调用)"""
if not HAVE_DB:
return
try:
db = SessionLocal()
db.query(SearchProvider).update({SearchProvider.usage_today: 0})
db.commit()
db.close()
logger.info("所有搜索提供商用量已重置")
except Exception as e:
logger.warning("重置用量失败: %s", e)
def _increment_usage(provider_id: int):
"""增加提供商当日用量"""
if not HAVE_DB:
return
try:
db = SessionLocal()
p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first()
if p:
p.usage_today = (p.usage_today or 0) + 1
p.last_used_at = __import__('datetime').datetime.now(__import__('datetime').timezone.utc)
db.commit()
db.close()
except Exception:
pass
def _call_baidu(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
import requests
resp = requests.post(
api_url or "https://qianfan.baidubce.com/v2/ai_search/web_search",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
"messages": [{"role": "user", "content": query}],
"search_source": "baidu_search_v2",
"resource_type_filter": [{"type": "web", "top_k": max_results}],
},
timeout=15
)
if resp.status_code != 200:
logger.warning("百度搜索返回 %s: %s", resp.status_code, resp.text[:100])
return []
data = resp.json()
results = data.get("results", []) or data.get("webPages", {}).get("value", [])
return [{
"title": r.get("title", "")[:120],
"url": r.get("url", "") or r.get("link", ""),
"content": r.get("snippet", "") or r.get("content", "") or r.get("summary", "")[:300],
"source": "baidu",
} for r in results[:max_results]]
def _call_qiniu(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
import requests
resp = requests.post(
api_url or "https://api.qnaigc.com/v1/search/web",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"query": query, "max_results": max_results, "search_type": "web"},
timeout=15
)
if resp.status_code != 200:
logger.warning("七牛搜索返回 %s: %s", resp.status_code, resp.text[:100])
return []
data = resp.json()
items = data.get("results", data.get("data", []))
return [{
"title": r.get("title", "")[:120],
"url": r.get("url", ""),
"content": r.get("content", "") or r.get("snippet", "")[:300],
"source": "qiniu",
} for r in items[:max_results]]
def _call_tinyfish(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
import requests
resp = requests.get(
api_url or "https://api.search.tinyfish.ai",
params={"query": query, "location": "CN", "language": "zh"},
headers={"X-API-Key": api_key},
timeout=15
)
if resp.status_code != 200:
logger.warning("TinyFish搜索返回 %s: %s", resp.status_code, resp.text[:100])
return []
data = resp.json()
items = data.get("results", [])
return [{
"title": r.get("title", "")[:120],
"url": r.get("url", ""),
"content": r.get("snippet", "")[:300],
"source": "tinyfish",
} for r in items[:max_results]]
def _call_bing(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
import requests
resp = requests.get(
api_url or "https://api.bing.microsoft.com/v7.0/search",
params={"q": query, "count": max_results, "mkt": "zh-CN"},
headers={"Ocp-Apim-Subscription-Key": api_key},
timeout=15
)
if resp.status_code != 200:
logger.warning("Bing搜索返回 %s: %s", resp.status_code, resp.text[:100])
return []
data = resp.json()
items = data.get("webPages", {}).get("value", [])
return [{
"title": r.get("name", "")[:120],
"url": r.get("url", ""),
"content": r.get("snippet", "")[:300],
"source": "bing",
} for r in items[:max_results]]
def _call_360(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
"""360搜索(HTML爬取,无需 API Key"""
from bs4 import BeautifulSoup
import requests
try:
resp = requests.get(
api_url or "https://www.so.com/s",
params={"q": query},
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"},
timeout=15,
)
if resp.status_code != 200:
logger.warning("360搜索返回 %s", resp.status_code)
return []
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for item in soup.select("li.res-list, li[class*=result], .rb"):
title_el = item.select_one("h3.res-title a, h3[class*=title] a, .res-title a")
if not title_el:
continue
title = title_el.get_text(strip=True)[:120]
url = title_el.get("href", "")
snippet_el = item.select_one("p.res-desc, p[class*=desc], .res-desc")
snippet = snippet_el.get_text(strip=True)[:300] if snippet_el else ""
results.append({"title": title, "url": url, "content": snippet, "source": "360"})
if len(results) >= max_results:
break
return results
except Exception as e:
logger.warning("360搜索失败: %s", e)
return []
def _call_sogou(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
"""搜狗搜索(HTML爬取,无需 API Key"""
from bs4 import BeautifulSoup
import requests
try:
resp = requests.get(
api_url or "https://sogou.com/web",
params={"query": query},
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"},
timeout=15,
)
if resp.status_code != 200:
logger.warning("搜狗搜索返回 %s", resp.status_code)
return []
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for item in soup.select("div.vrwrap, div[class*=vr], .rb"):
title_el = item.select_one("h3.vr-title a, h3[class*=title] a, .vr-title a")
if not title_el:
continue
title = title_el.get_text(strip=True)[:120]
url = title_el.get("href", "")
snippet_el = item.select_one("p.str-text, div.str-text, p[class*=str]")
snippet = snippet_el.get_text(strip=True)[:300] if snippet_el else ""
results.append({"title": title, "url": url, "content": snippet, "source": "sogou"})
if len(results) >= max_results:
break
return results
except Exception as e:
logger.warning("搜狗搜索失败: %s", e)
return []
def _call_wechat(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
"""微信搜一搜(通过搜狗抓取,无需 API Key)"""
from bs4 import BeautifulSoup
import requests
try:
resp = requests.get(
api_url or "https://wx.sogou.com/weixin",
params={"type": 2, "query": query},
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"},
timeout=15,
)
if resp.status_code != 200:
logger.warning("微信搜索返回 %s", resp.status_code)
return []
soup = BeautifulSoup(resp.text, "html.parser")
results = []
for item in soup.select("div.news-box, li.news-list, div[class*=news]"):
title_el = item.select_one("h3 a, .txt-box h3 a")
if not title_el:
continue
title = title_el.get_text(strip=True)[:120]
url = title_el.get("href", "")
if url and not url.startswith("http"):
url = "https://wx.sogou.com" + url
snippet_el = item.select_one("p.txt-info, div.txt-info, .txt-info")
snippet = snippet_el.get_text(strip=True)[:300] if snippet_el else ""
results.append({"title": title, "url": url, "content": snippet, "source": "wechat"})
if len(results) >= max_results:
break
return results
except Exception as e:
logger.warning("微信搜索失败: %s", e)
return []
_PROVIDER_CALLS = {
"baidu": _call_baidu,
"qiniu": _call_qiniu,
"tinyfish": _call_tinyfish,
"bing": _call_bing,
"360": _call_360,
"sogou": _call_sogou,
"wechat": _call_wechat,
}
def search(query: str, max_results: int = 5) -> List[Dict]:
"""统一搜索:DB提供商 → 本地缓存 → 空结果"""
providers = _get_providers()
for p in providers:
if (p.get("usage_today") or 0) >= (p.get("daily_limit") or 99999):
logger.info("提供商 %s 已达日限 %s,跳过", p.get("name"), p.get("daily_limit"))
continue
no_key_types = {"360", "sogou", "wechat"}
if not p.get("api_key") and p.get("provider_type") not in no_key_types:
logger.info("提供商 %s 未配置 API Key,跳过", p.get("name"))
continue
call_fn = _PROVIDER_CALLS.get(p.get("provider_type"))
if not call_fn:
continue
try:
results = call_fn(p["api_key"], p.get("api_url", ""), query, max_results)
if results:
_increment_usage(p["id"])
logger.info("搜索 '%s' 通过 %s 获得 %d 条结果", query[:20], p.get("name"), len(results))
return results
logger.warning("提供商 %s 返回空结果", p.get("name"))
except Exception as e:
logger.warning("提供商 %s 失败: %s", p.get("name"), e)
continue
logger.info("搜索 '%s' 无结果(所有提供商均不可用)", query[:20])
return []
def search_from_cache(query: str, max_results: int = 5) -> List[Dict]:
"""从本地缓存读取搜索结果"""
if not SEARCH_CACHE_FILE.exists():
return []
try:
cache = json.loads(SEARCH_CACHE_FILE.read_text(encoding="utf-8"))
meta = cache.get("_metadata", {})
updated = meta.get("updated_at", "")
if updated:
import datetime
age = (datetime.datetime.now() - datetime.datetime.fromisoformat(updated)).total_seconds()
if age > 129600:
logger.warning("搜索缓存过时(%dh),跳过", int(age // 3600))
return []
results = cache.get(query, [])
return results[:max_results]
except Exception:
return []
def enrich_topic_research(topic: dict, max_results: int = 5) -> str:
"""对选题进行网络搜索,返回格式化的研究发现文本"""
title = topic.get('title', '')
field = topic.get('field', '')
queries = [title]
if field and field not in title:
queries.append(f"{field} {title[:40]}")
seen_urls = set()
results = []
for q in queries:
for r in search(q, max_results):
url = r.get('url', '')
if url and url not in seen_urls:
seen_urls.add(url)
results.append(r)
if not results:
return ""
lines = ["\n## 网络搜索参考", ""]
for r in results[:max_results]:
snippet = r.get('snippet', r.get('content', ''))
lines.append(f"- **{r.get('title', '无标题')}**")
lines.append(f" {snippet[:200]}")
if r.get('url'):
lines.append(f" [{r['url']}]")
lines.append("")
return "\n".join(lines)