# -*- 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 结果:
...snippet
pattern = re.compile(
r']+class="b_algo"[^>]*>.*?]*>(.*?)
',
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'',
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]*?\1>', '', 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"([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)