#!/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_mcp(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]: """Call the MCP search server directly (no API key needed).""" import subprocess try: r = subprocess.run( [sys.executable, str(PROJECT_ROOT / "scripts" / "mcp_search_server.py"), "--query", query], capture_output=True, text=True, timeout=90, ) if r.returncode != 0: logger.warning("MCP搜索子进程返回非零: %s", r.stderr[:100]) return [] results = json.loads(r.stdout) if isinstance(results, list): for res in results: res["source"] = "opencode" return results[:max_results] except json.JSONDecodeError as e: logger.warning("MCP搜索JSON解析失败: %s", e) except subprocess.TimeoutExpired: logger.warning("MCP搜索超时 (90s)") except Exception as e: logger.warning("MCP搜索失败: %s", e) return [] 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, "mcp": _call_mcp, "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 = {"mcp", "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)