"""可插拔检索 provider + 网页抓取。 刻意避开 AGPL 许可的 SearXNG:通过商用/宽松许可的检索 API(默认 Tavily)实现, 未配置时返回空结果(流水线降级为无来源报告)。抓取正文用标准库 HTML→文本。 """ import httpx from config import config from htmltext import html_to_text _client = httpx.Client(timeout=30.0, follow_redirects=True, headers={"User-Agent": "GovAI-Research/1.0"}) def search(query: str, k: int) -> list[dict]: """返回 [{title, url, snippet, content}];未配置 provider 时返回 []。""" provider = (config.SEARCH_PROVIDER or "").lower() if provider == "tavily" and config.SEARCH_API_KEY: return _tavily(query, k) return [] def _tavily(query: str, k: int) -> list[dict]: try: resp = _client.post( f"{config.SEARCH_BASE_URL.rstrip('/')}/search", json={ "api_key": config.SEARCH_API_KEY, "query": query, "max_results": k, "include_raw_content": True, }, ) resp.raise_for_status() data = resp.json() except Exception: return [] out = [] for r in data.get("results", []) or []: out.append({ "title": r.get("title") or r.get("url", ""), "url": r.get("url", ""), "snippet": r.get("content", "") or "", "content": (r.get("raw_content") or r.get("content") or "")[:6000], }) return out def fetch_text(url: str) -> str: """抓取网页并抽取正文文本(失败返回空串)。""" try: resp = _client.get(url) resp.raise_for_status() ctype = resp.headers.get("content-type", "") if "html" in ctype or ctype == "": return html_to_text(resp.text) if "text" in ctype or "json" in ctype: return resp.text[:6000] except Exception: return "" return ""