Files
searxng-use-cli/scripts/common.py
T
thzxx 10c01a3abd feat(v2.5.0): Sec-Ch-Ua 头修复 + 结构化提取 + token 预算 + 正文去重 + 连接复用
正确性修复:
- 修复 Sec-Ch-Ua 构造 bug: 原实现产出 ""Not_A Brand";v="99"" 双重引号
  畸形头(Chrome/Edge 两路径), 严格校验的 WAF 会忽略; 改为品牌数组拼接
- search 403 快速失败: 实例级 403 不再退避重试(~10.5s 空等), 立即 failover
- AdaptiveThrottle: --throttle-failure-threshold 0 现为真正禁用语义
- number_of_results 缺失时用 len(results) 兜底(JSON/HTML 路径契约对齐)
- 版本对齐: pyproject.toml 与 _config.py 同步 2.5.0

AI 代理体验:
- fetch.py --extract json: 结构化骨架(title/meta/headings/links/images)
- fetch.py --max-chars N: 提取后语义级截断(区别于 --max-size 字节截断)
- search --fetch-total-chars N: --fetch 全局字符预算, 耗尽后 status=skipped
- --dedup-fetched-content: 抓取正文 SimHash 去重, status=duplicate
- --progress 新增 angle_start/ok/fail + fetch_skip/fetch_duplicate 事件
- fetch.py 补齐 --log-format json + --dump-schema
- CSV 媒体列自适应(images/videos 类别自动追加媒体字段列)
- --dry-run 批量模式打印实际查询列表
- search 连接复用: requests 可用时走模块级 Session(连接池)

工程治理:
- 新增 scripts/release_check.py 发布一致性检查(版本/错误码表漂移)
- 新增 tests/test_v250_features.py 46+4 个回归测试(全量 622 通过)
- tests/conftest.py: autouse fixture 强制 stdlib 路径(本机有 requests 时
  既有 urllib mock 测试不失效)
2026-08-07 15:19:17 +08:00

1090 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared utilities for searxng-cli scripts.
This module centralizes code that was previously duplicated across
``search.py`` and ``fetch.py``:
* ``build_auth_headers`` — construct an Authorization header from CLI flags
* ``resolve_auth_basic`` — resolve basic-auth credentials from file/env/CLI
(avoids leaving passwords in shell history)
* ``detect_charset`` — guess a response's text encoding
* ``is_retryable_error`` — unified transient-error policy (urllib + requests)
* ``FALLBACK_UAS`` — browser-like User-Agents used when blocked
* retry constants — ``RETRYABLE_STATUS``, ``RETRY_BACKOFF_BASE``, etc.
* ``setup_logging`` — shared logging configuration (--verbose/--quiet)
* ``force_utf8_stdout`` — force stdout to UTF-8 (fix Windows GBK crashes)
Centralizing the retry policy guarantees that both scripts treat 429/5xx
as retryable and connection errors as transient, eliminating the previous
inconsistency where ``search.py`` ignored 5xx.
"""
import base64
import datetime
import hashlib
import io
import json
import logging
import os
import random
import re
import sys
import threading
import urllib.error
import urllib.parse
from pathlib import Path
# Root logger for the searxng-cli package. All modules create child loggers
# via ``logging.getLogger("searxng.<module>")`` so a single setup_logging()
# call controls them all.
_LOG = logging.getLogger("searxng")
# Brotli 解压支持检测(v2.2.1)。
# requests 库自动解压 gzip/deflate,但**不自动解压 Brotli**(除非安装
# brotli/brotlicffi 包)。若在 Accept-Encoding 中声明 br 而系统未安装
# 解压器,服务器返回的 br 压缩字节会被当作文本解码 → 全页乱码。
# 此检测用于 build_browser_headers() 智能声明 Accept-Encoding,避免
# 声明无法兑现的 br。
try:
import brotli as _brotli # type: ignore
_HAS_BROTLI = True
except ImportError:
try:
import brotlicffi as _brotli # type: ignore
_HAS_BROTLI = True
except ImportError:
_brotli = None
_HAS_BROTLI = False
def setup_logging(verbose: bool = False, quiet: bool = False,
log_format: str = "text",
request_id: str = None) -> None:
"""Configure the ``searxng`` logger hierarchy.
* Default (no flags): ``INFO`` — progress messages, warnings, retry notices.
Matches the previous ``print(..., file=sys.stderr)`` behavior so existing
scripts and agents see no change.
* ``--verbose`` / ``-v``: ``DEBUG`` — also shows HTTP request URLs, response
status codes, cache keys, and other diagnostic detail.
* ``--quiet`` / ``-q``: ``WARNING`` — suppresses progress and retry noise;
only warnings and errors reach stderr.
* ``--log-format json`` (v2.2.0): 每行一个 JSON 对象,便于 AI Agent
程序化解析。包含 ts/level/logger/msg/request_id 字段。
* request_id (v2.2.0): 贯穿所有日志和进度事件的请求标识符。
All log output goes to stderr; stdout is reserved for data (JSON/CSV/etc.).
"""
if verbose:
level = logging.DEBUG
elif quiet:
level = logging.WARNING
else:
level = logging.INFO
_LOG.setLevel(level)
# 存储 request_id 到 logger 全局,供 formatter 和进度事件使用
_LOG._request_id = request_id
# Avoid duplicate handlers if setup_logging() is called twice (e.g. tests).
if not _LOG.handlers:
handler = logging.StreamHandler(sys.stderr)
if log_format == "json":
handler.setFormatter(_JsonFormatter())
else:
handler.setFormatter(logging.Formatter("%(message)s"))
_LOG.addHandler(handler)
else:
# 已有 handler(如测试环境),更新 formatter
for h in _LOG.handlers:
if log_format == "json":
h.setFormatter(_JsonFormatter())
else:
h.setFormatter(logging.Formatter("%(message)s"))
# Don't let root logger add its own handler — we own the searxng namespace.
_LOG.propagate = False
class _JsonFormatter(logging.Formatter):
"""JSON 结构化日志 formatterv2.2.0)。
每行输出一个 JSON 对象:{"ts", "level", "logger", "msg", "request_id"}。
让 AI Agent 可程序化解析日志(统计重试次数、识别慢实例等)。
"""
def format(self, record):
entry = {
"ts": _datetime_iso(record),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
rid = getattr(_LOG, "_request_id", None)
if rid:
entry["request_id"] = rid
if record.exc_info and record.exc_info[1]:
entry["exception"] = type(record.exc_info[1]).__name__
return json.dumps(entry, ensure_ascii=False)
def _datetime_iso(record):
"""格式化日志时间戳为 ISO 8601 字符串。"""
return datetime.datetime.fromtimestamp(record.created).isoformat(timespec="milliseconds")
def force_utf8_stdout() -> None:
"""Force stdout/stderr to UTF-8 to prevent Windows GBK encoding crashes.
Windows Python defaults ``sys.stdout`` to the OEM codepage (often GBK on
Chinese Windows). ``print()`` of any character outside that codepage
(e.g. ``\\xa0`` nbsp, CJK punctuation from foreign pages) raises
``UnicodeEncodeError`` and kills the process.
``PYTHONIOENCODING=utf-8`` is unreliable here because Python 3.7+
reconfigures stdout after reading that env var in some scenarios (e.g.
when stdout has already been wrapped). The only reliable fix is to
reconfigure the stream in-process.
Uses ``sys.stdout.reconfigure()`` on Python 3.7+, falling back to
wrapping ``sys.stdout.buffer`` on older versions. Both paths use
``errors='replace'`` so an undecodable byte never crashes the script —
better to emit ``?`` than to lose all output.
Safe to call multiple times; subsequent calls are no-ops once the
encoding is already UTF-8 (or close enough — we check the lowercased
encoding name to tolerate ``utf-8`` vs ``UTF-8`` vs ``utf8``).
"""
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
# Already UTF-8? Skip (covers Linux/macOS and re-invoked scripts).
enc = getattr(stream, "encoding", "") or ""
if enc.lower().replace("-", "") in ("utf8", "utf-8-sig"):
continue
# Python 3.7+ has TextIOWrapper.reconfigure()
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8", errors="replace")
continue
except (ValueError, OSError):
pass # Fall through to the buffer-wrap path
# Fallback: wrap the underlying buffer in a new UTF-8 stream.
buffer = getattr(stream, "buffer", None)
if buffer is not None:
try:
new_stream = io.TextIOWrapper(
buffer, encoding="utf-8", errors="replace", line_buffering=True,
)
setattr(sys, stream_name, new_stream)
except (ValueError, AttributeError):
# Last resort: keep the original stream. Better to risk a
# GBK crash on exotic characters than to break stdout entirely.
pass
# Retry settings (shared by both scripts)
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 1.5 # seconds; exponential backoff + jitter
RETRY_BACKOFF_CAP = 60.0 # 退避上限:1.5*2^N 无封顶时 N=10 达 1536s,会卡死进程
# HTTP status codes worth retrying. 403 is included so fetch_url's UA-fallback
# loop can kick in when a site blocks the default searxng-cli User-Agent
# (a fresh UA is tried on each retry attempt). search.py also retries 403 —
# it doesn't switch UAs, so true auth failures waste ~3 attempts, accepted
# as a trade-off for one shared retry policy across both scripts.
RETRYABLE_STATUS = frozenset({403, 429, 502, 503, 504})
# Browser-like UA strings for fallback when the searxng-cli UA is blocked.
#
# v2.2.2:单一来源(SSOT)。UA 池唯一权威定义在 _config.UA_POOL
# 此处直接导入——删除原有的内置副本 _FALLBACK_UAS_BUILTIN。
# v2.2.0 曾保留一份手工同步副本,两份列表漂移会导致跨脚本 UA 行为不一致
# fetch.py 用 FALLBACK_UAS 轮换、search.py 的 --dry-run 报告引用同一池),
# 且注释要求"保持同步"无任何机制保证。直接引用同一对象后,改一处即全局生效。
#
# 维护原则(见 _config.UA_POOL 注释):
# 1. 版本号保持为当前年份的主流浏览器版本
# 2. 顺序固定——get_ua_for_domain() 用 SHA-256 哈希选索引
# 3. 至少覆盖 Chrome/Edge/Firefox × Windows/macOS/Linux
from _config import UA_POOL as FALLBACK_UAS
def _ua_index_for_domain(domain: str, pool_size: int) -> int:
"""为域名确定性选择 UA 池索引。
用 ``hashlib.sha256`` 而非内置 ``hash()``,因为后者对字符串做了
随机化(PYTHONHASHSEED),跨进程不可复现。SHA-256 保证同一域名
永远映射到同一索引,跨进程一致——这对调试和日志分析至关重要。
"""
h = hashlib.sha256(domain.encode("utf-8")).digest()
# 取前 8 字节作为无符号整数,避免负数和短字符串的分布不均
return int.from_bytes(h[:8], "big") % pool_size
# Per-domain UA 缓存:同一域名 + 同一进程 = 同一 UA,避免会话内 UA 突变
# 被反爬识别。跨进程通过 SHA-256 哈希复现,见 _ua_index_for_domain。
_domain_ua_cache: dict = {}
# 保护 _domain_ua_cache 的"检查-设置"原子性锁。
# v2.2.0:并发场景下(如 search_multi 并行请求多实例),多个线程可能同时
# 检查 domain not in cache 并同时写入,虽不致命但会浪费计算且可能写入不同
# UA(因 hash 本应稳定,但极端时序下逻辑可读性问题)。用锁串行化 dict 读写。
# 注意:锁内只做 dict 读写,绝不放网络/重计算,避免阻塞其他线程。
_domain_ua_lock = threading.Lock()
def get_ua_for_domain(url: str, user_agent: str = None) -> str:
"""返回适合某域名的 User-Agent。
优先级:
1. ``user_agent`` 显式传入(CLI --user-agent)→ 直接返回
2. 该域名已缓存 → 返回缓存值
3. 域名未缓存 → 用 SHA-256 hash 选一个 FALLBACK_UAS,缓存并返回
设计理由:真实浏览器访问同一站点时 UA 永远不变。爬虫如果每次请求
换一个 UA,反而会被反爬系统标记为可疑。确定性轮换保证同一域名
稳定使用同一 UA,不同域名分散到不同 UA 上降低集体封禁风险。
线程安全:缓存的"检查-设置"用 ``_domain_ua_lock`` 保护。本函数无网络
调用,但遵循"锁内只做 dict 读写"原则——hash 计算放在锁外,写入时做
双检查(其他线程可能在此期间已写入),兼顾正确性与并发吞吐。
"""
if user_agent:
return user_agent
try:
domain = urllib.parse.urlparse(url).netloc.lower()
if not domain:
return FALLBACK_UAS[0]
except Exception:
return FALLBACK_UAS[0]
# 快速路径:锁内检查缓存命中
with _domain_ua_lock:
if domain in _domain_ua_cache:
return _domain_ua_cache[domain]
# 缓存未命中:在锁外计算 UA(SHA-256 hash,无副作用,不阻塞其他线程)
idx = _ua_index_for_domain(domain, len(FALLBACK_UAS))
ua = FALLBACK_UAS[idx]
# 加锁写入;双检查避免覆盖其他线程并发写入的值
with _domain_ua_lock:
if domain in _domain_ua_cache:
return _domain_ua_cache[domain]
_domain_ua_cache[domain] = ua
return ua
def reset_domain_ua_cache() -> None:
"""清空 per-domain UA 缓存。测试用。"""
with _domain_ua_lock:
_domain_ua_cache.clear()
def build_browser_headers(user_agent: str, referer: str = None,
accept_html: bool = True) -> dict:
"""构造完整的浏览器请求头,让请求看起来像真浏览器。
v2.0.0 核心反爬措施:仅靠 User-Agent 已无法绕过现代 WAF
Cloudflare/Akamai/Imperva 都会检查 Sec-* 头和 Accept-Language。
Args:
user_agent: UA 字符串(应来自 get_ua_for_domain
referer: Referer URL(可选;从搜索结果抓取时设为实例 URL)
accept_html: True 时 Accept 包含 text/html(页面抓取);
False 时 Accept 为 application/jsonAPI 调用)
Returns:
包含完整浏览器指纹的 headers dict。调用方需自行合并 auth_headers。
"""
is_firefox = "Firefox/" in user_agent
is_edge = "Edg/" in user_agent
if accept_html:
accept = ("text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8")
else:
accept = "application/json, text/plain, */*;q=0.8"
# v2.2.1 智能声明 Accept-Encoding:仅当本机安装了 brotli 解压器时
# 才声明 br。否则服务器返回 br 压缩字节而 requests 无法解压 → 乱码。
# Firefox UA 路径保持只声明 gzip/deflate(与真 Firefox 行为一致,
# Firefox 虽支持 br 但为减少指纹差异在此工具中不声明)。
if is_firefox:
accept_encoding = "gzip, deflate"
elif _HAS_BROTLI:
accept_encoding = "gzip, deflate, br"
else:
accept_encoding = "gzip, deflate"
headers = {
"User-Agent": user_agent,
"Accept": accept,
"Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7",
"Accept-Encoding": accept_encoding,
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1" if accept_html else "0",
}
# Sec-Ch-Ua 系列仅 Chrome/Edge 发送,Firefox 不发
if not is_firefox:
# 从 UA 提取主版本号,构造 Sec-Ch-Ua。
# v2.5.0 修复:原实现把已含引号的 not_a_brand 再包进 f-string 引号,
# 生成 ""Not_A Brand";v="99"" 的畸形头——Chrome/Edge 两条路径都中招,
# 严格校验 Sec-CH-UA 的 WAFCloudflare 等会校验与 UA 一致性)会直接
# 忽略或判定不一致。现改为品牌数组统一拼接,产出合法格式:
# Chrome: "Not_A Brand";v="99", "Chromium";v="138", "Google Chrome";v="138"
# Edge: 上述 + , "Microsoft Edge";v="138"
m = re.search(r"Chrome/(\d+)", user_agent)
ver = m.group(1) if m else "131"
not_a_brand = "Not_A Brand" if ver != "99" else "Not/A)Brand"
brands = [
f'"{not_a_brand}";v="99"',
f'"Chromium";v="{ver}"',
f'"Google Chrome";v="{ver}"',
]
if is_edge:
# Edge 额外声明 Microsoft Edge 品牌
brands.append(f'"Microsoft Edge";v="{ver}"')
headers["Sec-Ch-Ua"] = ", ".join(brands)
headers["Sec-Ch-Ua-Mobile"] = '"?1"' if "Mobile" in user_agent else '"?0"'
# 平台标识
if "Windows" in user_agent:
headers["Sec-Ch-Ua-Platform"] = '"Windows"'
elif "Macintosh" in user_agent:
headers["Sec-Ch-Ua-Platform"] = '"macOS"'
elif "Linux" in user_agent:
headers["Sec-Ch-Ua-Platform"] = '"Linux"'
# Sec-Fetch 系列(Chrome 76+ 全量发送)
if accept_html:
headers["Sec-Fetch-Site"] = "none" if not referer else "cross-site"
headers["Sec-Fetch-Mode"] = "navigate"
headers["Sec-Fetch-User"] = "?1"
headers["Sec-Fetch-Dest"] = "document"
else:
headers["Sec-Fetch-Site"] = "same-origin" if referer else "none"
headers["Sec-Fetch-Mode"] = "cors"
headers["Sec-Fetch-Dest"] = "empty"
if referer:
headers["Referer"] = referer
return headers
def parse_retry_after(header_value: str) -> float:
"""解析 Retry-After header,返回应等待的秒数。
HTTP 规范允许两种格式:
1. 纯数字:秒数(最常见)
2. HTTP date:绝对时间(如 ``Wed, 21 Oct 2026 07:28:00 GMT``
返回 0.0 表示无需等待或解析失败。对 HTTP date 格式,若已过期
也返回 0.0(让调用方立即重试)。
"""
if not header_value:
return 0.0
header_value = header_value.strip()
# 格式 1:纯数字秒数
try:
seconds = float(header_value)
return max(0.0, seconds)
except ValueError:
pass
# 格式 2HTTP date
try:
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
dt = parsedate_to_datetime(header_value)
if dt is None:
return 0.0
# 确保 timezone-aware
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
delta = (dt - now).total_seconds()
return max(0.0, delta)
except (TypeError, ValueError, OverflowError):
return 0.0
def compute_backoff_delay(attempt: int, base: float = RETRY_BACKOFF_BASE,
cap: float = RETRY_BACKOFF_CAP) -> float:
"""计算退避延迟,带封顶和抖动。
``base * 2^attempt + jitter``,但不超过 ``cap``。
v2.0.0 新增封顶:原公式无上限,N=10 时达 1536s 会卡死进程。
"""
delay = base * (2 ** attempt) + random.uniform(0, 1)
return min(delay, cap)
def build_auth_headers(bearer_token: str = None, basic_auth: str = None) -> dict:
"""Build an Authorization header dict from CLI auth flags.
``bearer_token``: raw Bearer token string.
``basic_auth``: ``"username:password"`` string (base64-encoded).
If both are provided, Bearer takes precedence (more common for APIs).
Returns a dict to merge into request headers, or an empty dict.
"""
headers = {}
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
elif basic_auth:
encoded = base64.b64encode(basic_auth.encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {encoded}"
return headers
def _warn_file_perms(path: str) -> None:
"""Warn if a credentials file is readable by group/other (POSIX only).
On Windows the Unix permission bits in ``st_mode`` do not reflect the
actual ACL, so the check is skipped to avoid false alarms.
"""
if os.name != "posix":
return
log = logging.getLogger("searxng.common")
try:
mode = os.stat(path).st_mode & 0o777
if mode & 0o077:
log.warning(
f"Warning: credentials file '{path}' has permissions {oct(mode)} "
f"(accessible by group/other); recommend 'chmod 600' for security."
)
except OSError:
pass
def resolve_auth_basic(cli_value: str = None, file_path: str = None,
env_var: str = "SEARXNG_BASIC_AUTH",
config_value: str = None) -> str:
"""Resolve basic-auth credentials without leaking them via shell history.
Priority (highest wins):
1. ``cli_value`` — explicit ``--auth-basic "user:pass"`` (convenient
but leaks into shell history; discouraged)
2. ``file_path`` — ``--auth-basic-file FILE``; first non-empty line
is read as ``user:pass``. Recommended for shells.
3. ``config_value`` — ``auth_basic`` field from ``searxng.toml``.
Convenient for AI agents that read config once.
4. ``env_var`` — ``SEARXNG_BASIC_AUTH`` environment variable.
Returns ``"user:pass"`` or ``None`` if no source provides credentials.
Raises ``RuntimeError`` if a file is specified but cannot be read.
"""
if cli_value:
return cli_value
if file_path:
try:
text = Path(file_path).read_text(encoding="utf-8")
_warn_file_perms(file_path)
for line in text.splitlines():
line = line.strip()
if line and not line.startswith("#"):
return line
raise RuntimeError(f"auth file '{file_path}' contains no credentials")
except OSError as e:
raise RuntimeError(f"cannot read auth file '{file_path}': {e}") from e
if config_value:
return config_value
return os.environ.get(env_var)
def resolve_auth_bearer(cli_value: str = None, file_path: str = None,
env_var: str = "SEARXNG_BEARER_TOKEN",
config_value: str = None) -> str:
"""Resolve a Bearer token from CLI flag, file, config, or environment.
Mirrors :func:`resolve_auth_basic` for token-style auth. Useful for
long-lived API tokens that should not appear in shell history.
Priority (highest wins):
1. ``cli_value`` — explicit ``--auth-bearer "token"``
2. ``file_path`` — ``--auth-bearer-file FILE``
3. ``config_value`` — ``auth_bearer`` field from ``searxng.toml``
4. ``env_var`` — ``SEARXNG_BEARER_TOKEN`` environment variable
"""
if cli_value:
return cli_value
if file_path:
try:
text = Path(file_path).read_text(encoding="utf-8")
_warn_file_perms(file_path)
for line in text.splitlines():
line = line.strip()
if line and not line.startswith("#"):
return line
raise RuntimeError(f"token file '{file_path}' contains no token")
except OSError as e:
raise RuntimeError(f"cannot read token file '{file_path}': {e}") from e
if config_value:
return config_value
return os.environ.get(env_var)
def apply_proxy(proxy_url: str) -> None:
"""Configure proxy via environment variables.
Sets ``HTTP_PROXY`` and ``HTTPS_PROXY`` so both urllib (which reads
them via :func:`urllib.request.getproxies`) and ``requests`` (which
honors them when ``trust_env=True``, the default) pick up the proxy
without any changes to call sites.
``NO_PROXY`` is set to ``localhost,127.0.0.1,::1`` (if not already set)
so local traffic stays direct — matters for self-hosted SearXNG on
localhost behind a corporate proxy.
Pass an empty string to clear the proxy env vars (rarely needed; the
default unset state already means "no proxy").
"""
if not proxy_url:
return
os.environ["HTTP_PROXY"] = proxy_url
os.environ["HTTPS_PROXY"] = proxy_url
# Keep local traffic direct unless the user has explicitly set NO_PROXY
os.environ.setdefault("NO_PROXY", "localhost,127.0.0.1,::1")
def detect_charset(raw: bytes, content_type: str) -> str:
"""Detect charset from the Content-Type header, then an HTML meta tag.
Falls back to UTF-8 (with replacement) if nothing reliable is found.
"""
# 1. HTTP header
if "charset=" in content_type:
charset = content_type.split("charset=")[-1].split(";")[0].strip()
try:
raw.decode(charset)
return charset
except (UnicodeDecodeError, LookupError):
pass
# 2. HTML <meta charset> or <meta http-equiv>
try:
head = raw[:4096].decode("ascii", errors="replace")
m = re.search(r'<meta[^>]+charset=["\']?([a-zA-Z0-9_-]+)', head, re.IGNORECASE)
if m:
charset = m.group(1).strip()
try:
raw.decode(charset)
return charset
except (UnicodeDecodeError, LookupError):
pass
except Exception:
pass
# 3. Fallback: UTF-8 with replacement
return "utf-8"
def is_retryable_error(exc: BaseException) -> bool:
"""Return True if ``exc`` is a transient error worth retrying.
Handles both the stdlib ``urllib`` errors and ``requests`` errors via
duck-typing (so this module does not need to import ``requests``):
* ``urllib.error.HTTPError`` → retry iff status in ``RETRYABLE_STATUS``
* ``urllib.error.URLError`` / ``OSError`` / ``TimeoutError`` → retry
(connection refused, DNS failure, timeout — all transient)
* ``requests.exceptions.HTTPError`` → retry iff ``response.status_code``
is in ``RETRYABLE_STATUS``
* ``requests`` connection/timeout errors (no ``.response``) → retry
"""
if isinstance(exc, urllib.error.HTTPError):
return exc.code in RETRYABLE_STATUS
if isinstance(exc, (urllib.error.URLError, OSError, TimeoutError)):
return not isinstance(exc, urllib.error.HTTPError)
# requests.exceptions.HTTPError / RequestException (duck-typed)
resp = getattr(exc, "response", None)
status = getattr(resp, "status_code", None)
if status is not None:
return status in RETRYABLE_STATUS
if resp is None:
# requests connection/timeout error without a response -> transient
return True
return False
# ----- Structured error classification -----
#
# 错误码体系:让 AI Agent 程序化地判断错误类型并采取恢复策略。
# 所有错误码以 E_ 前缀,在 --format json 模式下随 error_code 字段输出。
#
# AI 可根据 error_code 决策:
# E_CONFIG → 检查实例配置/环境变量,提示用户设置
# E_AUTH → 检查 token/凭证,提示用户重新认证
# E_NETWORK → 重试或切换实例/代理
# E_RATE_LIMIT → 等待后重试,降低请求频率
# E_PARSE → 检查实例是否支持 JSON,尝试 HTML 回退
# E_EMPTY → 调整查询词或时间范围
# E_INPUT → 修正参数/文件路径
# E_INTERNAL → 报告 bug,附带完整错误信息
# 错误码常量(供 search.py / fetch.py 引用)
E_CONFIG = "E_CONFIG"
E_AUTH = "E_AUTH"
E_BLOCKED = "E_BLOCKED" # fetch 被站点反爬拦截(403),非凭证问题
E_NETWORK = "E_NETWORK"
E_RATE_LIMIT = "E_RATE_LIMIT"
E_PARSE = "E_PARSE"
E_EMPTY = "E_EMPTY"
E_INPUT = "E_INPUT"
E_INTERNAL = "E_INTERNAL"
# 每个 E_* 配套的可操作恢复建议,让 AI Agent 能自决策下一步动作,
# 而不是盲目重试或放弃。在 _emit_error 的 JSON 输出中作为 recovery_hint 字段。
RECOVERY_HINTS = {
E_CONFIG: "Provide -i/--instance, set SEARXNG_INSTANCE env var, or create "
"searxng.toml/instances.txt config file.",
E_AUTH: "Verify --auth-bearer/--auth-basic credentials or "
"SEARXNG_BEARER_TOKEN/SEARXNG_BASIC_AUTH env vars. Check token "
"expiry and instance access permissions.",
E_BLOCKED: "The site is blocking automated access (WAF / anti-bot / "
"geo-block) — this is NOT a credentials problem; no auth "
"change will help. Use the Wayback Machine fallback (on by "
"default; --no-fallback disables), fetch a mirror or a "
"different URL, or drop the domain with --exclude-domain.",
E_NETWORK: "Retry with backoff, or try a different SearXNG instance. "
"Check network connectivity, proxy settings, and instance uptime.",
E_RATE_LIMIT: "Wait before retrying (exponential backoff). Reduce query "
"frequency, narrow --time-range, or distribute load across "
"multiple instances.",
E_INPUT: "Check query syntax, --categories values, --time-range format, "
"and flag combinations. Use --help for valid options.",
E_PARSE: "The instance returned malformed data. Try a different instance, "
"switch --method, or check if the instance version is compatible.",
E_EMPTY: "Refine the query (more specific terms), broaden --time-range, "
"add --categories, or increase --pageno to find more results.",
E_INTERNAL: "This is likely a bug. Re-run with --verbose and report the "
"full output for diagnosis.",
}
def _classify_by_type_and_status(exc) -> str:
"""根据异常类型和 HTTP 状态码分类,返回错误码或 None。
检查顺序:HTTP 状态码 → 文件错误 → 连接错误 → 解析错误。
返回 None 表示该异常无法靠类型/状态码判定,需走字符串 fallback。
不处理 RuntimeError 消息推断(由 :func:`classify_error` 调用方做 fallback)。
抽取为独立函数,便于 :func:`classify_error` 对 ``exc`` 本身和其
``__cause__`` 复用同一套基于真实类型的判定逻辑。
"""
# HTTP 错误(urllib HTTPError 用 .coderequests HTTPError 用 .response.status_code
status = None
if isinstance(exc, urllib.error.HTTPError):
status = exc.code
else:
resp = getattr(exc, "response", None)
status = getattr(resp, "status_code", None)
if status is not None:
if status == 429:
return E_RATE_LIMIT
if status in (401, 403):
return E_AUTH
if 400 <= status < 500:
return E_INPUT
if 500 <= status < 600:
return E_NETWORK
# 文件/输入错误(FileNotFoundError 是 OSError 子类,必须先于 OSError 检查)
if isinstance(exc, FileNotFoundError):
return E_INPUT
# 连接级错误
if isinstance(exc, (urllib.error.URLError, OSError, TimeoutError)):
return E_NETWORK
if isinstance(exc, ConnectionError):
return E_NETWORK
# 解析错误
if isinstance(exc, (ValueError, json.JSONDecodeError)):
return E_PARSE
return None
def classify_error(exc: BaseException) -> str:
"""将异常分类为结构化错误码,供 AI Agent 程序化处理。
分类逻辑(按优先级):
1. 优先检查异常链 ``__cause__````raise X from Y`` 场景下 Y 才是真实
错误源(如 ``raise RuntimeError(...) from HTTPError(403)``),
用 Y 的类型/状态码分类比从 X 的消息字符串推断更可靠、不再脆弱。
2. HTTP 状态码:429→E_RATE_LIMIT, 401/403→E_AUTH, 4xx→E_INPUT, 5xx→E_NETWORK
3. 连接类异常(ConnectionError/TimeoutError/URLError/OSError)→ E_NETWORK
4. 解析错误(ValueError/JSONDecodeError)→ E_PARSE
5. FileNotFoundError → E_INPUT
6. RuntimeError:从消息中推断(仅当 ``__cause__`` 缺失时的最后 fallback
兼容旧路径——search_multi 把 last_error 拼进消息)
7. 其他 → E_INTERNAL
"""
# 优先检查异常链 __cause__raise X from Y 时,Y 指向真实底层异常。
# 旧逻辑只能从外层 RuntimeError 的消息字符串推断(脆弱,依赖 "403"/"auth"
# 等关键字匹配),新逻辑直接从 __cause__ 的 .code/.status_code 或异常
# 类型判定。仅检查一层 __cause__,不递归——单层已覆盖 search_multi 的
# raise-from 模式,深层链罕见且递归有循环风险。
cause = getattr(exc, "__cause__", None)
if cause is not None and cause is not exc:
code = _classify_by_type_and_status(cause)
if code is not None:
return code
# 检查 exc 本身的类型和状态码
code = _classify_by_type_and_status(exc)
if code is not None:
return code
# RuntimeError:从消息中推断(search_multi 的 "All N instances failed.
# Last error: HTTP Error 403: Forbidden" 等)。并行路径现在会把 last_error
# 拼进消息,让这里能提取真实错误类型,而不是一律误判 E_NETWORK。
# 字符串匹配仅作为 __cause__ 缺失时的最后 fallback。
if isinstance(exc, RuntimeError):
msg = str(exc).lower()
# 先检查 auth/rate-limit 关键字(最常见,来自 last_error 详情)
if "auth" in msg or "403" in msg or "401" in msg:
return E_AUTH
if "rate" in msg or "429" in msg:
return E_RATE_LIMIT
# 从 "http error NNN" / "http NNN" 模式中提取状态码,
# 正确分类 "All instances failed. Last error: HTTP Error 403" 等
status_match = re.search(r'http(?: error)? (\d{3})', msg)
if status_match:
status = int(status_match.group(1))
if status == 429:
return E_RATE_LIMIT
if status in (401, 403):
return E_AUTH
if 400 <= status < 500:
return E_INPUT
if 500 <= status < 600:
return E_NETWORK
# 所有实例失败的通用模式(无更具体的 HTTP 状态码时才判为网络错误)
if "all" in msg and "instance" in msg and "fail" in msg:
return E_NETWORK
if "parse" in msg or "json" in msg or "html" in msg:
return E_PARSE
if "not found" in msg or ("no " in msg and "instance" in msg):
return E_CONFIG
return E_INTERNAL
return E_INTERNAL
def _extract_status_code(exc) -> int:
"""从异常及其 ``__cause__`` 链中提取 HTTP 状态码(无则返回 None)。
检查顺序:urllib ``.code`` → requests ``.response.status_code`` →
消息中的 ``HTTP NNN`` 模式(fetch_url 抛出的 RuntimeError 消息)。
"""
target = getattr(exc, "__cause__", None) or exc
status = getattr(target, "code", None)
if status is None:
resp = getattr(target, "response", None)
status = getattr(resp, "status_code", None)
if status is None:
m = re.search(r"HTTP (\d{3})", str(exc))
if m:
status = int(m.group(1))
return status
def classify_fetch_error(exc: BaseException, status_code: int = None) -> str:
"""fetch 场景的错误分类:403 → E_BLOCKED401 → E_AUTH,其余委托 classify_error。
网页抓取(fetch.py / search.py --fetch)遇到的 403 绝大多数是站点反爬
拦截(UA/JS 指纹、WAF、区域封锁),**不是凭证错误**。classify_error
把 401/403 统一归为 E_AUTH 是为 SearXNG 实例认证设计的——若 fetch 也
用它,AI Agent 会误判为"需要检查凭证"而去做无效的认证重试。本函数在
classify_error 基础上仅做 fetch 场景的细分。
``status_code`` 可选:调用方已从异常链提取时可直接传入,避免重复解析。
"""
if status_code is None:
status_code = _extract_status_code(exc)
if status_code == 403:
return E_BLOCKED
if status_code == 401:
return E_AUTH
return classify_error(exc)
# ----- Progress event emitter (for --progress flag) -----
#
# 当 --progress 启用时,search.py 会调用 emit_progress() 发射结构化事件到
# stderrJSON Lines 格式)。AI Agent 可解析这些事件来跟踪执行进度。
#
# 事件类型:
# {"event": "start", "query": "...", "instances": N}
# {"event": "instance_try", "url": "...", "attempt": 1}
# {"event": "instance_ok", "url": "...", "latency": 0.5, "results": 10}
# {"event": "instance_fail", "url": "...", "error": "...", "error_code": "E_*"}
# {"event": "cache_hit", "query": "...", "ttl": 30}
# {"event": "cache_store", "query": "...", "ttl": 30}
# {"event": "fetch_start", "count": 3}
# {"event": "fetch_ok", "url": "...", "chars": 1234}
# {"event": "fetch_fail", "url": "...", "error": "..."}
# {"event": "done", "results": N, "query": "..."}
# {"event": "error", "error": "...", "error_code": "E_*", "query": "..."}
_progress_enabled = False
def set_progress_enabled(enabled: bool) -> None:
"""全局开关:是否向 stderr 输出 JSON Lines 格式的进度事件。"""
global _progress_enabled
_progress_enabled = enabled
def emit_progress(event: str, **kwargs) -> None:
"""向 stderr 输出一行 JSON 格式的进度事件。
仅当 --progress 启用时才输出。``event`` 是事件类型字符串,
``kwargs`` 是事件的附加字段。输出格式为 JSON Lines(每行一个 JSON 对象)。
v2.2.0:自动注入 request_id(如果已设置)。
"""
if not _progress_enabled:
return
payload = {"event": event}
rid = getattr(_LOG, "_request_id", None)
if rid:
payload["request_id"] = rid
payload.update(kwargs)
print(json.dumps(payload, ensure_ascii=False), file=sys.stderr, flush=True)
# ----- Wayback Machine 兜底(v2.1.0 共享逻辑)-----
# 被 search.py 和 fetch.py 共用,避免逻辑漂移。
def should_try_wayback(error_msg: str) -> bool:
"""判断是否应触发 Wayback Machine 兜底。
触发条件:错误信息暗示 404/403/超时/连接重置等可恢复失败。
不触发:DNS 失败(Wayback 也访问不到)、空错误。
纯字符串判断,无副作用,可安全用于 fetch.py 和 search.py。
"""
if not error_msg:
return False
msg = error_msg.lower()
triggers = ["404", "403", "timeout", "timed out", "connection reset",
"connection refused", "max retries exceeded",
"connectionreset", "connectionaborted"]
return any(t in msg for t in triggers)
def build_wayback_url(url: str) -> str:
"""构造 Wayback Machine 最新快照 URL。
使用 ``https://web.archive.org/web/2/<url>`` 端点,``2`` 表示
"最新可用快照"。Wayback 会 302 重定向到具体时间戳快照。
"""
return f"https://web.archive.org/web/2/{url}"
# ----- 被墙/强反爬站点智能回退(v2.1.0)-----
# 这些站点在中国大陆环境下常见 403/ConnectionReset,且对 UA 轮换不敏感
# (有更深层的反爬:Cookie/JS 指纹/登录墙)。命中时自动优先 Wayback 兜底。
#
# 维护原则:
# 1. 只收录"几乎必 403"的站点,避免误伤可正常抓取的站点
# 2. 每个站点都经过真实环境验证
# 3. 列表按域名匹配(子域名也算命中)
HARD_BLOCKED_DOMAINS = frozenset([
"www.baidu.com", # 百度搜索:强反爬 + Cookie 检测
"baike.baidu.com", # 百度百科:强反爬 + Cookie 检测
"zhidao.baidu.com", # 百度知道:同上
"tieba.baidu.com", # 百度贴吧:同上
"wenku.baidu.com", # 百度文库:同上
"zhihu.com", # 知乎:登录墙 + 反爬
"zhuanlan.zhihu.com", # 知乎专栏:同上
"mp.weixin.qq.com", # 微信公众号:强反爬 + 登录墙
"weibo.com", # 微博:登录墙 + 反爬
"m.weibo.cn", # 微博移动版:同上
"douban.com", # 豆瓣:反爬 + 频率限制
"www.douban.com", # 豆瓣主站
"book.douban.com", # 豆瓣读书
"movie.douban.com", # 豆瓣电影
])
# 需要子域匹配的域名(如 *.zhihu.com, *.weibo.com, *.douban.com
# 注意:baidu.com 不在此列——其反爬子域(www/baike/zhidao/wenku/tieba)已在
# 上方精确列表中,而 pan.baidu.com(网盘)/cloud.baidu.com(智能云)等
# 子域可正常抓取,整体匹配会误伤。zhihu/weibo/douban 的子域基本都被墙。
_SUBDOMAIN_BLOCKED = frozenset([
"zhihu.com",
"weibo.com",
"douban.com",
])
def is_hard_blocked_domain(url: str) -> bool:
"""判断 URL 是否属于已知的强反爬/被墙站点。
匹配逻辑:
1. 精确匹配 HARD_BLOCKED_DOMAINS(如 baike.baidu.com
2. 子域匹配 _SUBDOMAIN_BLOCKED(如 *.zhihu.com
命中时调用方应:
* 主抓取失败后立即尝试 Wayback(不等 should_try_wayback 判断)
* 或直接跳过主抓取,优先 Wayback
"""
if not url:
return False
# 提取域名
try:
host = urllib.parse.urlparse(url).hostname or ""
except Exception:
host = ""
if not host:
return False
host = host.lower().lstrip(".")
# 精确匹配
if host in HARD_BLOCKED_DOMAINS:
return True
# 子域匹配:xxx.zhihu.com → 匹配 zhihu.com
parts = host.split(".")
if len(parts) >= 2:
# 检查最后两段(如 zhihu.com)或最后三段(如 baike.baidu.com
for i in range(len(parts) - 1):
suffix = ".".join(parts[i:])
if suffix in _SUBDOMAIN_BLOCKED or suffix in HARD_BLOCKED_DOMAINS:
return True
return False
# ----- 相似度去重(v2.x:让 AI Agent 在研究模式下获得更干净的 merged_results-----
# 同一内容在不同 URL/引擎下常重复出现,仅靠 URL 去重无法合并。
# SimHash 对标题做局部敏感哈希,汉明距离小的视为近似重复。
def _normalize_title(title: str) -> str:
"""归一化标题:小写、去标点、去多余空格,用于相似度比较。"""
if not title:
return ""
s = title.lower()
# 保留字母、数字、CJK 和空格,其余替换为空格
s = re.sub(r'[^\w\s]', ' ', s)
# \w 包含下划线,单独去掉
s = s.replace('_', ' ')
s = re.sub(r'\s+', ' ', s).strip()
return s
def _simhash(text: str, hash_bits: int = 64) -> int:
"""计算文本的 SimHash 指纹。
- 分词(按空格 + CJK 单字符)
- 每个 token 算普通 hash,按 bit 投票
- 返回 hash_bits 位的指纹
"""
if not text:
return 0
# 分词:按空格切分,CJK 字符再逐个拆成单字 token
tokens = []
for word in text.split():
buf = []
for ch in word:
if '\u4e00' <= ch <= '\u9fff':
# 遇到 CJK:先冲出缓冲区里的非 CJK 片段,再加入单字
if buf:
tokens.append(''.join(buf))
buf = []
tokens.append(ch)
else:
buf.append(ch)
if buf:
tokens.append(''.join(buf))
if not tokens:
return 0
# 每个 token 算 SHA-256(跨进程可复现,避免 hash() 随机化),按 bit 投票
v = [0] * hash_bits
for token in tokens:
h = hashlib.sha256(token.encode('utf-8')).digest()
token_hash = int.from_bytes(h[:8], 'big')
for i in range(hash_bits):
if (token_hash >> i) & 1:
v[i] += 1
else:
v[i] -= 1
# 投票为正的位置 1
fingerprint = 0
for i in range(hash_bits):
if v[i] > 0:
fingerprint |= (1 << i)
return fingerprint
def _hamming_distance(a: int, b: int) -> int:
"""两个整数的汉明距离。"""
return bin(a ^ b).count('1')
def _jaccard_similarity(set_a: set, set_b: set) -> float:
"""Jaccard 相似度。"""
if not set_a and not set_b:
return 0.0
union = set_a | set_b
if not union:
return 0.0
return len(set_a & set_b) / len(union)
def is_similar(result_a: dict, result_b: dict, threshold: float = 0.85) -> bool:
"""判断两个搜索结果是否相似。
- 优先用标题 SimHash(汉明距离 <= 3 视为相似,对应 64 位中约 95% 相似)
- 标题太短(< 5 字符)时用 URL 域名 + 标题 Jaccard
- threshold 参数控制严格程度
"""
title_a = _normalize_title(result_a.get("title", ""))
title_b = _normalize_title(result_b.get("title", ""))
# 标题太短时 SimHash 不稳定,改用 Jaccard
if len(title_a) < 5 or len(title_b) < 5:
domain_a = urllib.parse.urlparse(result_a.get("url", "")).netloc.lower()
domain_b = urllib.parse.urlparse(result_b.get("url", "")).netloc.lower()
set_a = set(title_a.split()) | {domain_a}
set_b = set(title_b.split()) | {domain_b}
return _jaccard_similarity(set_a, set_b) >= threshold
# 标题足够长:用 SimHash 汉明距离
hash_a = _simhash(title_a)
hash_b = _simhash(title_b)
# threshold → 汉明距离阈值映射:
# 0.85 → 3(默认,宽松),0.90 → 2,0.95 → 1,1.0 → 0(几乎完全相同)
max_distance = max(0, min(3, int(round((1.0 - threshold) / 0.05))))
return _hamming_distance(hash_a, hash_b) <= max_distance
def texts_are_similar(text_a: str, text_b: str, threshold: float = 0.85) -> bool:
"""判断两段正文是否近似重复(v2.5.0,供抓取正文去重用)。
镜像/转载站点的正文与原文高度相似但 URL 不同——URL 级去重无法合并,
需要按正文指纹判定。复用与 :func:`is_similar` 相同的
SimHash + 汉明距离逻辑,但针对**正文**而非标题:
* 归一化(小写/去标点/CJK 单字分词)后取前 1000 字符做指纹窗口——
镜像页前 1000 字符通常一致,长文全量计算只会稀释指纹且增加开销
* 空文本(len<窗口)返回 False,避免短错误文本误判为重复
* threshold → 汉明距离阈值映射与 is_similar 一致(0.85→3 ... 1.0→0
"""
norm_a = _normalize_title(text_a)[:1000]
norm_b = _normalize_title(text_b)[:1000]
if len(norm_a) < 5 or len(norm_b) < 5:
return False
hash_a = _simhash(norm_a)
hash_b = _simhash(norm_b)
max_distance = max(0, min(3, int(round((1.0 - threshold) / 0.05))))
return _hamming_distance(hash_a, hash_b) <= max_distance