# -*- coding: utf-8 -*- """ search_engine.py v1.0 — 多搜索引擎聚合(零依赖) ==================================================== 支持的搜索引擎(按优先级): 1. BingAPISearch — 有 BING_API_KEY 时启用(最稳定) 2. GoogleAPISearch — 有 GOOGLE_API_KEY + GOOGLE_CSE_ID 时启用 3. DuckDuckGoHTML — html.duckduckgo.com/html/(零依赖默认) 4. SearXNGSearch — 公共实例列表(零依赖但可用性看实例) 5. BingHTML / GoogleHTML — 解析 SERP HTML(易反爬,仅 fallback) 用法: from search_engine import MultiEngineSearch s = MultiEngineSearch(engines=["duckduckgo"]) results = s.search("贵州茅台 2024 年报", limit=10) for r in results: print(r.title, r.url) """ from __future__ import annotations import json import os import random import re import time import urllib.parse import urllib.request import zlib from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field, asdict from datetime import datetime from html.parser import HTMLParser from pathlib import Path from typing import Dict, List, Optional, Any # ============== 数据模型 ============== @dataclass class SearchResult: title: str url: str snippet: str source_engine: str rank: int = 0 fetched_at: str = "" credibility: int = 5 # 0-10 def to_dict(self) -> Dict[str, Any]: return asdict(self) # ============== 基类 ============== class SearchEngineBase: """搜索引擎基类。""" name: str = "base" credibility: int = 5 needs_api_key: bool = False def __init__(self, timeout: int = 8): self.timeout = timeout def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: raise NotImplementedError def _http_get(self, url: str, headers: Optional[Dict[str, str]] = None) -> Optional[str]: """HTTP GET,返回 text/None。""" req = urllib.request.Request(url) req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/131.0.0.0 Safari/537.36") req.add_header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") if headers: for k, v in headers.items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: # 解码 raw = resp.read() charset = resp.headers.get_content_charset() or "utf-8" try: return raw.decode(charset, errors="replace") except (LookupError, TypeError): return raw.decode("utf-8", errors="replace") except Exception: return None # ============== DuckDuckGo HTML(零依赖默认) ============== class DuckDuckGoHTML(SearchEngineBase): """DuckDuckGo HTML 端点解析(无需 API key)。""" name = "duckduckgo" credibility = 7 BASE_URL = "https://html.duckduckgo.com/html/" # UA 轮换池 _UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", ] def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: results: List[SearchResult] = [] data = urllib.parse.urlencode({"q": query}).encode("utf-8") # 最多重试 3 次,每次换 UA for attempt in range(3): if results: break req = urllib.request.Request(self.BASE_URL, data=data, method="POST") ua = random.choice(self._UA_POOL) req.add_header("User-Agent", ua) req.add_header("Content-Type", "application/x-www-form-urlencoded") req.add_header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: html = resp.read().decode("utf-8", errors="replace") results = _parse_ddg_html(html, self.name, self.credibility, limit) except Exception: if attempt < 2: time.sleep(random.uniform(0.5, 1.5)) continue return results def _parse_ddg_html(html: str, engine: str, credibility: int, limit: int) -> List[SearchResult]: """解析 DuckDuckGo HTML 结果。""" results: List[SearchResult] = [] # DDG 的结果结构:title + ... # 用正则简单提取 pattern = re.compile( r']+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)' r'.*?]+class="result__snippet"[^>]*>(.*?)', re.DOTALL | re.IGNORECASE ) fetched_at = datetime.now().isoformat() for i, m in enumerate(pattern.finditer(html)): if i >= limit: break url = _unescape_html(m.group(1)) title = _strip_tags(m.group(2)).strip() snippet = _strip_tags(m.group(3)).strip() results.append(SearchResult( title=title, url=url, snippet=snippet, source_engine=engine, rank=i + 1, fetched_at=fetched_at, credibility=credibility, )) return results # ============== Bing HTML / Google HTML(fallback) ============== class BingHTML(SearchEngineBase): """Bing SERP HTML 解析(易反爬)。""" name = "bing_html" credibility = 6 BASE_URL = "https://www.bing.com/search" # UA 轮换池(模拟真实浏览器) _UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", ] def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: results: List[SearchResult] = [] url = f"{self.BASE_URL}?q={urllib.parse.quote(query)}&count={limit}" # 限制搜索中文内容 url += "&setlang=zh-Hans&cc=cn" for attempt in range(3): if results: break ua = random.choice(self._UA_POOL) headers = { "User-Agent": ua, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Referer": "https://www.bing.com/", } html = self._http_get(url, headers=headers) if not html: if attempt < 2: time.sleep(random.uniform(1.0, 2.5)) continue # Bing 结果:
  • title

    ...

    snippet

    pattern = re.compile( r']+class="b_algo"[^>]*>.*?

    \s*]+href="([^"]+)"[^>]*>(.*?)' r'.*?]*>(.*?)

    ', re.DOTALL | re.IGNORECASE ) # 备用模式:Bing 有时使用不同结构 alt_pattern = re.compile( r']+class="[^"]*tilk[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)', re.DOTALL | re.IGNORECASE ) fetched_at = datetime.now().isoformat() for i, m in enumerate(pattern.finditer(html)): if i >= limit: break results.append(SearchResult( title=_strip_tags(m.group(2)), url=m.group(1), snippet=_strip_tags(m.group(3)), source_engine=self.name, rank=i + 1, fetched_at=fetched_at, credibility=self.credibility, )) # 主模式没匹配到,尝试备用模式 if not results: for i, m in enumerate(alt_pattern.finditer(html)): if i >= limit: break results.append(SearchResult( title=_strip_tags(m.group(2)), url=m.group(1), snippet="", source_engine=self.name, rank=i + 1, fetched_at=fetched_at, credibility=self.credibility, )) if results or attempt >= 2: break time.sleep(random.uniform(1.0, 2.5)) return results class BaiduHTML(SearchEngineBase): """百度 SERP HTML 解析(v5.0.0 新增)— 中文财经召回最佳,大陆可达性最优。""" name = "baidu_html" credibility = 8 BASE_URL = "https://www.baidu.com/s" _UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", ] @staticmethod def _resolve_baidu_url(href: str) -> str: """还原百度 /link?url= 跳转链接为真实目标 URL。""" if "/link?url=" in href: qs = urllib.parse.parse_qs(urllib.parse.urlparse(href).query) real = qs.get("url") if real: return real[0] return href def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: results: List[SearchResult] = [] url = f"{self.BASE_URL}?wd={urllib.parse.quote(query)}&rn={limit}&ie=utf-8" for attempt in range(3): if results: break ua = random.choice(self._UA_POOL) headers = { "User-Agent": ua, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Referer": "https://www.baidu.com/", } html = self._http_get(url, headers=headers) if not html: if attempt < 2: time.sleep(random.uniform(1.0, 2.5)) continue # 主模式:

    标题

    pattern = re.compile( r']*>\s*]+href="([^"]+)"[^>]*>(.*?)\s*

    ', re.DOTALL | re.IGNORECASE ) # 摘要:c-abstract / content-right_8Zs40 等容器 snip_pattern = re.compile( r']+class="[^"]*(?:c-abstract|content-right_8Zs40)[^"]*"[^>]*>(.*?)', re.DOTALL | re.IGNORECASE ) snippets = [_strip_tags(m.group(1)) for m in snip_pattern.finditer(html)] fetched_at = datetime.now().isoformat() for i, m in enumerate(pattern.finditer(html)): if i >= limit: break title = _strip_tags(m.group(2)).strip() if not title: continue results.append(SearchResult( title=title, url=self._resolve_baidu_url(m.group(1)), snippet=snippets[i] if i < len(snippets) else "", source_engine=self.name, rank=i + 1, fetched_at=fetched_at, credibility=self.credibility, )) if results or attempt >= 2: break time.sleep(random.uniform(1.0, 2.5)) return results class SogouHTML(SearchEngineBase): """搜狗 SERP HTML 解析(v5.0.0 新增)— 微信公众号内容检索入口。""" name = "sogou_html" credibility = 7 BASE_URL = "https://www.sogou.com/web" _UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", ] @staticmethod def _resolve_sogou_url(href: str) -> str: """还原搜狗 /link?url= 跳转链接为真实目标 URL。""" if "/link?url=" in href: qs = urllib.parse.parse_qs(urllib.parse.urlparse(href).query) real = qs.get("url") if real: return real[0] return href def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: results: List[SearchResult] = [] url = f"{self.BASE_URL}?query={urllib.parse.quote(query)}" for attempt in range(3): if results: break ua = random.choice(self._UA_POOL) headers = { "User-Agent": ua, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Referer": "https://www.sogou.com/", } html = self._http_get(url, headers=headers) if not html: if attempt < 2: time.sleep(random.uniform(1.0, 2.5)) continue # 主模式:

    标题

    pattern = re.compile( r']*class="[^"]*vr-title[^"]*"[^>]*>\s*]+href="([^"]+)"[^>]*>(.*?)', re.DOTALL | re.IGNORECASE ) # 摘要:star-wiki / txt-info / space-txt 等容器 snip_pattern = re.compile( r']+class="[^"]*(?:star-wiki|txt-info|space-txt)[^"]*"[^>]*>(.*?)

    ', re.DOTALL | re.IGNORECASE ) snippets = [_strip_tags(m.group(1)) for m in snip_pattern.finditer(html)] fetched_at = datetime.now().isoformat() for i, m in enumerate(pattern.finditer(html)): if i >= limit: break title = _strip_tags(m.group(2)).strip() if not title: continue results.append(SearchResult( title=title, url=self._resolve_sogou_url(m.group(1)), snippet=snippets[i] if i < len(snippets) else "", source_engine=self.name, rank=i + 1, fetched_at=fetched_at, credibility=self.credibility, )) if results or attempt >= 2: break time.sleep(random.uniform(1.0, 2.5)) return results class GoogleHTML(SearchEngineBase): """Google SERP HTML 解析(最易反爬,仅作 fallback)。""" name = "google_html" credibility = 6 BASE_URL = "https://www.google.com/search" def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: results: List[SearchResult] = [] url = f"{self.BASE_URL}?q={urllib.parse.quote(query)}&num={limit}" html = self._http_get(url, headers={"Accept-Language": "en-US,en;q=0.9"}) if not html: return results # Google 结果解析复杂,先用简化版本(可能不准确) pattern = re.compile( r']+href="([^"]+)"[^>]*>\s*]*>(.*?)', re.DOTALL | re.IGNORECASE ) fetched_at = datetime.now().isoformat() for i, m in enumerate(pattern.finditer(html)): if i >= limit: break url_m = m.group(1) if url_m.startswith("/url?q="): url_m = urllib.parse.unquote(url_m[7:].split("&")[0]) elif not url_m.startswith("http"): continue results.append(SearchResult( title=_strip_tags(m.group(2)), url=url_m, snippet="", source_engine=self.name, rank=i + 1, fetched_at=fetched_at, credibility=self.credibility, )) return results # ============== SearXNG ============== class SearXNGSearch(SearchEngineBase): """SearXNG 元搜索 — 自动健康检查 + 故障转移。""" name = "searxng" credibility = 6 DEFAULT_INSTANCES = [ "https://searx.be", "https://search.disroot.org", "https://searx.tiekoetter.com", "https://search.sapti.me", "https://searx.work", ] # 类级别健康缓存 _healthy_instance: Optional[str] = None _last_health_check: float = 0.0 _health_check_ttl: float = 300.0 # 5 分钟 def __init__(self, timeout: int = 8, instance: Optional[str] = None): super().__init__(timeout) self.instance = instance or self._get_healthy_instance() @classmethod def _get_healthy_instance(cls) -> str: """返回可用实例,优先使用缓存。""" now = time.time() if (cls._healthy_instance and now - cls._last_health_check < cls._health_check_ttl): return cls._healthy_instance # 探测所有实例 for inst in cls.DEFAULT_INSTANCES: try: req = urllib.request.Request( f"{inst}/search?q=test&format=json", headers={"User-Agent": "Mozilla/5.0 (compatible; cn-financial-scraper/4.5)"}, ) with urllib.request.urlopen(req, timeout=5) as resp: if resp.getcode() == 200: cls._healthy_instance = inst cls._last_health_check = now return inst except Exception: continue # 全部失败,用第一个默认 return cls.DEFAULT_INSTANCES[0] def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: results: List[SearchResult] = [] # 尝试所有实例 instances_to_try = [self.instance] + [ i for i in self.DEFAULT_INSTANCES if i != self.instance ] for inst in instances_to_try: if results: break url = f"{inst}/search?q={urllib.parse.quote(query)}&format=json&language=zh-CN" try: req = urllib.request.Request(url) req.add_header("User-Agent", "Mozilla/5.0 (compatible; cn-financial-scraper/4.5)") req.add_header("Accept", "application/json") with urllib.request.urlopen(req, timeout=self.timeout) as resp: data = json.loads(resp.read().decode("utf-8", errors="replace")) fetched_at = datetime.now().isoformat() for i, r in enumerate(data.get("results", [])[:limit]): results.append(SearchResult( title=r.get("title", ""), url=r.get("url", ""), snippet=r.get("content", ""), source_engine=self.name, rank=i + 1, fetched_at=fetched_at, credibility=self.credibility, )) # 成功:更新健康实例 if results: SearXNGSearch._healthy_instance = inst SearXNGSearch._last_health_check = time.time() except Exception: continue return results # ============== 官方 Search API ============== class BingAPISearch(SearchEngineBase): """Bing Search API v7(需 BING_API_KEY)。""" name = "bing_api" credibility = 9 needs_api_key = True ENDPOINT = "https://api.bing.microsoft.com/v7.0/search" def __init__(self, api_key: str, timeout: int = 8): super().__init__(timeout) self.api_key = api_key def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: results: List[SearchResult] = [] if not self.api_key: return results params = urllib.parse.urlencode({"q": query, "count": limit, "mkt": "zh-CN"}) url = f"{self.ENDPOINT}?{params}" try: req = urllib.request.Request(url) req.add_header("Ocp-Apim-Subscription-Key", self.api_key) with urllib.request.urlopen(req, timeout=self.timeout) as resp: data = json.loads(resp.read().decode("utf-8", errors="replace")) except Exception: return results fetched_at = datetime.now().isoformat() for i, item in enumerate(data.get("webPages", {}).get("value", [])[:limit]): results.append(SearchResult( title=item.get("name", ""), url=item.get("url", ""), snippet=item.get("snippet", ""), source_engine=self.name, rank=i + 1, fetched_at=fetched_at, credibility=self.credibility, )) return results class GoogleAPISearch(SearchEngineBase): """Google Custom Search JSON API(需 GOOGLE_API_KEY + GOOGLE_CSE_ID)。""" name = "google_api" credibility = 10 needs_api_key = True ENDPOINT = "https://www.googleapis.com/customsearch/v1" def __init__(self, api_key: str, cse_id: str, timeout: int = 8): super().__init__(timeout) self.api_key = api_key self.cse_id = cse_id def search(self, query: str, limit: int = 10, **kwargs) -> List[SearchResult]: results: List[SearchResult] = [] if not (self.api_key and self.cse_id): return results params = urllib.parse.urlencode({ "key": self.api_key, "cx": self.cse_id, "q": query, "num": min(limit, 10), }) url = f"{self.ENDPOINT}?{params}" try: with urllib.request.urlopen(url, timeout=self.timeout) as resp: data = json.loads(resp.read().decode("utf-8", errors="replace")) except Exception: return results fetched_at = datetime.now().isoformat() for i, item in enumerate(data.get("items", [])[:limit]): results.append(SearchResult( title=item.get("title", ""), url=item.get("link", ""), snippet=item.get("snippet", ""), source_engine=self.name, rank=i + 1, fetched_at=fetched_at, credibility=self.credibility, )) return results # ============== 聚合器 ============== class MultiEngineSearch: """多引擎聚合搜索 + 去重 + 排序。""" ENGINE_REGISTRY = { "baidu_html": BaiduHTML, "sogou_html": SogouHTML, "duckduckgo": DuckDuckGoHTML, "bing_html": BingHTML, "google_html": GoogleHTML, "searxng": SearXNGSearch, "bing_api": "api", # 特殊:需 API key "google_api": "api", } def __init__(self, engines: Optional[List[str]] = None, api_keys: Optional[Dict[str, str]] = None, timeout: int = 8, max_workers: int = 3): """ Args: engines: 启用的引擎列表(None → 按可用性自动选) 可选: "duckduckgo", "bing_html", "google_html", "searxng", "bing_api", "google_api" api_keys: API key 字典 {"bing_api": "xxx", "google_api_key": "yyy", "google_cse_id": "zzz"} timeout: 单引擎超时(秒) max_workers: 并发线程数 """ self.api_keys = api_keys or {} self.timeout = timeout self.max_workers = max_workers self.engines: List[SearchEngineBase] = [] if engines is None: engines = self._auto_detect_engines() for name in engines: engine = self._create_engine(name) if engine: self.engines.append(engine) def _auto_detect_engines(self) -> List[str]: """根据 API key 可用性自动选引擎。""" # v5.0.0: 国内引擎优先(大陆可达性最优),零依赖 engines = ["baidu_html", "sogou_html", "duckduckgo"] if self.api_keys.get("bing_api"): engines.insert(0, "bing_api") if self.api_keys.get("google_api_key") and self.api_keys.get("google_cse_id"): engines.insert(0, "google_api") # SearXNG 作为补充 engines.append("searxng") return engines def _create_engine(self, name: str) -> Optional[SearchEngineBase]: """根据名称创建引擎实例。""" if name == "baidu_html": return BaiduHTML(timeout=self.timeout) if name == "sogou_html": return SogouHTML(timeout=self.timeout) if name == "duckduckgo": return DuckDuckGoHTML(timeout=self.timeout) if name == "bing_html": return BingHTML(timeout=self.timeout) if name == "google_html": return GoogleHTML(timeout=self.timeout) if name == "searxng": return SearXNGSearch(timeout=self.timeout) if name == "bing_api" and self.api_keys.get("bing_api"): return BingAPISearch(self.api_keys["bing_api"], timeout=self.timeout) if name == "google_api" and self.api_keys.get("google_api_key") \ and self.api_keys.get("google_cse_id"): return GoogleAPISearch( self.api_keys["google_api_key"], self.api_keys["google_cse_id"], timeout=self.timeout, ) return None def search(self, query: str, engines: Optional[List[str]] = None, limit: int = 10, dedup: bool = True) -> List[SearchResult]: """跨引擎搜索。 Args: query: 关键词 engines: 临时覆盖启用的引擎 limit: 每引擎返回条数 dedup: 是否跨引擎去重 Returns: 合并去重后的 SearchResult 列表(按 credibility + rank 排序) """ selected = self.engines if engines: selected = [e for e in self.engines if e.name in engines] all_results: List[SearchResult] = [] with ThreadPoolExecutor(max_workers=min(self.max_workers, len(selected))) as ex: futures = {ex.submit(_safe_search, eng, query, limit): eng for eng in selected} for fut in as_completed(futures): try: results = fut.result() all_results.extend(results) except Exception: continue if dedup: all_results = _dedup_results(all_results) # 排序:先按 credibility 降序,再按 rank all_results.sort(key=lambda r: (-r.credibility, r.rank)) # 限制返回条数 return all_results[:limit * 2] def list_engines(self) -> List[Dict[str, Any]]: """列出已启用引擎及其状态。""" return [{ "name": e.name, "credibility": e.credibility, "needs_api_key": e.needs_api_key, "configured": bool(e), } for e in self.engines] def search_and_fetch(self, query: str, engines: Optional[List[str]] = None, limit: int = 5, fetch_content: bool = True, dedup: bool = True) -> List[Dict[str, Any]]: """搜索 → 获取 URL 列表 → 逐条爬取详情页内容。 Args: query: 搜索关键词 engines: 临时覆盖启用的引擎 limit: 搜索返回条数 fetch_content: 是否自动爬取详情页文本 dedup: 是否去重 Returns: 结构化结果列表,每项含 {title, url, snippet, source_engine, content} """ results = self.search(query, engines=engines, limit=limit, dedup=dedup) output: List[Dict[str, Any]] = [] for r in results[:limit]: item = { "title": r.title, "url": r.url, "snippet": r.snippet, "source_engine": r.source_engine, "credibility": r.credibility, "content": "", "fetch_error": "", } if fetch_content: try: fetched = self._fetch_page_text(r.url) item["content"] = fetched.get("text", "")[:3000] item["fetch_error"] = fetched.get("error", "") except Exception: item["fetch_error"] = "fetch failed" output.append(item) return output def _fetch_page_text(self, url: str) -> Dict[str, str]: """爬取页面文本(轻量版,只提取文字)。""" try: req = urllib.request.Request( url, headers={ "User-Agent": "Mozilla/5.0 (compatible; cn-financial-scraper/4.5)", "Accept-Language": "zh-CN,zh;q=0.9", }, ) with urllib.request.urlopen(req, timeout=self.timeout) as resp: raw = resp.read() charset = resp.headers.get_content_charset() or "utf-8" try: html = raw.decode(charset, errors="replace") except (LookupError, TypeError): html = raw.decode("utf-8", errors="replace") # 提取文本:去掉 script/style 标签,保留文本 html = re.sub(r'<(script|style)[^>]*>[\s\S]*?', '', html, flags=re.IGNORECASE) text = re.sub(r'<[^>]+>', ' ', html) text = re.sub(r'\s+', ' ', text).strip() return {"text": text[:3000]} except Exception as e: return {"text": "", "error": str(e)[:100]} # ============== 辅助函数 ============== def _safe_search(engine: SearchEngineBase, query: str, limit: int) -> List[SearchResult]: """线程安全的单引擎搜索。""" try: return engine.search(query, limit=limit) except Exception: return [] def _dedup_results(results: List[SearchResult]) -> List[SearchResult]: """按 URL 标准化去重。""" seen: Dict[str, SearchResult] = {} for r in results: norm = _normalize_url(r.url) if not norm: continue if norm not in seen: seen[norm] = r else: # 保留 credibility 更高的 if r.credibility > seen[norm].credibility: seen[norm] = r return list(seen.values()) def _normalize_url(url: str) -> str: """URL 标准化(去 utm 等)。""" try: parsed = urllib.parse.urlparse(url) if not parsed.netloc: return "" # 移除 tracking 参数 params = urllib.parse.parse_qs(parsed.query) for k in list(params.keys()): if k.startswith("utm_") or k in ("ref", "source"): params.pop(k, None) new_query = urllib.parse.urlencode(params, doseq=True) return f"{parsed.scheme}://{parsed.netloc}{parsed.path}" except Exception: return url def _strip_tags(html: str) -> str: """简单 HTML 标签剥离。""" text = re.sub(r"<[^>]+>", "", html) return _unescape_html(text) def _unescape_html(s: str) -> str: """HTML 实体解码。""" s = s.replace("&", "&").replace("<", "<").replace(">", ">") s = s.replace(""", '"').replace("'", "'").replace(" ", " ") s = re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), s) s = re.sub(r"&#x([0-9a-fA-F]+);", lambda m: chr(int(m.group(1), 16)), s) return s # ============== 便捷函数 ============== def quick_search(query: str, engines: Optional[List[str]] = None, limit: int = 10) -> List[Dict[str, Any]]: """便捷函数:返回字典列表。""" api_keys = { "bing_api": os.environ.get("BING_API_KEY", ""), "google_api_key": os.environ.get("GOOGLE_API_KEY", ""), "google_cse_id": os.environ.get("GOOGLE_CSE_ID", ""), } s = MultiEngineSearch(engines=engines, api_keys=api_keys) return [r.to_dict() for r in s.search(query, limit=limit)] def search_and_fetch(query: str, engines: Optional[List[str]] = None, limit: int = 5, fetch_content: bool = True) -> List[Dict[str, Any]]: """便捷函数:搜索 + 爬取详情内容。 用法: results = search_and_fetch("贵州茅台 2026 年报") for r in results: print(r["title"], r["url"], len(r.get("content", ""))) """ api_keys = { "bing_api": os.environ.get("BING_API_KEY", ""), "google_api_key": os.environ.get("GOOGLE_API_KEY", ""), "google_cse_id": os.environ.get("GOOGLE_CSE_ID", ""), } s = MultiEngineSearch(engines=engines, api_keys=api_keys) return s.search_and_fetch(query, limit=limit, fetch_content=fetch_content)