"""通用搜索客户端(Exa 优先,Tavily fallback)。 为 build_glossary.py 这类术语核查场景服务。 关键设计: - `trust_env=False` 绕开系统 socks 代理(Clash on macOS 配 socks5 时 httpx 会 TLS EOF) - Exa 优先:LinkedIn / 官网 / 百度百科返回质量最高 - 遇到配额问题自动降级到 Tavily 或返回 empty - 不做深度 crawl,只要摘要 """ from __future__ import annotations import os from dataclasses import dataclass from typing import Any import httpx @dataclass class SearchHit: title: str url: str snippet: str class SearchError(RuntimeError): pass class ExaClient: def __init__(self, api_key: str | None = None, timeout: float = 30.0) -> None: self.api_key = api_key or os.environ.get("EXA_API_KEY") if not self.api_key: raise SearchError("EXA_API_KEY not set") # trust_env=False 关键:不吃系统代理,避免 TLS EOF self._client = httpx.Client(trust_env=False, timeout=timeout) def close(self) -> None: self._client.close() def __enter__(self) -> "ExaClient": return self def __exit__(self, *_args: Any) -> None: self.close() def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]: body = { "query": query, "numResults": num_results, "type": "auto", "contents": {"text": {"maxCharacters": 800}}, } r = self._client.post( "https://api.exa.ai/search", json=body, headers={"x-api-key": self.api_key, "Content-Type": "application/json"}, ) if r.status_code != 200: raise SearchError(f"Exa HTTP {r.status_code}: {r.text[:200]}") data = r.json() out: list[SearchHit] = [] for item in data.get("results", [])[:num_results]: out.append( SearchHit( title=(item.get("title") or "")[:200], url=item.get("url") or "", snippet=(item.get("text") or item.get("snippet") or "")[:600], ) ) return out class TavilyClient: def __init__(self, api_key: str | None = None, timeout: float = 30.0) -> None: self.api_key = api_key or os.environ.get("TAVILY_API_KEY") if not self.api_key: raise SearchError("TAVILY_API_KEY not set") self._client = httpx.Client(trust_env=False, timeout=timeout) def close(self) -> None: self._client.close() def __enter__(self) -> "TavilyClient": return self def __exit__(self, *_args: Any) -> None: self.close() def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]: body = { "api_key": self.api_key, "query": query, "search_depth": "basic", "max_results": num_results, "include_answer": False, "include_raw_content": False, } r = self._client.post("https://api.tavily.com/search", json=body) if r.status_code != 200: raise SearchError(f"Tavily HTTP {r.status_code}: {r.text[:200]}") data = r.json() out: list[SearchHit] = [] for item in data.get("results", [])[:num_results]: out.append( SearchHit( title=(item.get("title") or "")[:200], url=item.get("url") or "", snippet=(item.get("content") or "")[:600], ) ) return out class SearchClient: """统一搜索门面:先用 Exa,失败/配额问题降级 Tavily。""" def __init__(self) -> None: self._exa: ExaClient | None = None self._tavily: TavilyClient | None = None try: self._exa = ExaClient() except SearchError: pass try: self._tavily = TavilyClient() except SearchError: pass if not (self._exa or self._tavily): raise SearchError( "neither EXA_API_KEY nor TAVILY_API_KEY available" ) def close(self) -> None: if self._exa: self._exa.close() if self._tavily: self._tavily.close() def __enter__(self) -> "SearchClient": return self def __exit__(self, *_args: Any) -> None: self.close() def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]: # 优先 Exa if self._exa: try: return self._exa.search(query, num_results=num_results) except SearchError as e: msg = str(e).lower() if "exceed" in msg or "quota" in msg or "429" in msg or "402" in msg: # 降级 pass else: # 其它错误继续往下试 pass if self._tavily: try: return self._tavily.search(query, num_results=num_results) except SearchError: pass return [] if __name__ == "__main__": from scripts.lib.zenmux_client import load_secrets load_secrets() with SearchClient() as c: hits = c.search("Mabwell 迈威生物 biopharmaceutical", num_results=3) for i, h in enumerate(hits, 1): print(f"[{i}] {h.title[:80]}") print(f" {h.url}") print(f" {h.snippet[:160]}")