feat(v1.8.1): Windows 兼容性修复 + SKILL.md 铁律区块

Windows 兼容性修复(基于真实使用痛点):

- force_utf8_stdout(): 强制 stdout/stderr 为 UTF-8,修复 Windows GBK 崩溃(print('\\xa0') 不再炸)

- resolve_instances/load_config 新增 %APPDATA%/searxng-cli/ 路径,覆盖 Windows 配置约定

- fetch.py 失败诊断增强:输出 status_code=/cause=/url= 字段,AI Agent 可程序化区分 404/403/DNS 失败

SKILL.md 铁律区块(5 条,置顶):

- stdout=数据/stderr=日志 永不混淆

- 禁用 2>/dev/null(丢弃 stderr = 失败时零诊断)

- 排错去 --quiet 加 --verbose

- 配置查找覆盖 WSL + Windows 双路径

- 实例 URL 必填,公共实例发现已移除

测试: 352 -> 362(新增 10 个:force_utf8_stdout 幂等性/GBK 替换/非 ASCII 打印/APPDATA 路径发现/txt 回退/空 APPDATA)
This commit is contained in:
2026-08-01 20:50:42 +08:00
parent 657af0a221
commit f7cdc81c7f
8 changed files with 325 additions and 12 deletions
+1 -1
View File
@@ -7,6 +7,6 @@ Retry settings and shared HTTP utilities now live in ``common.py`` so that
both ``search.py`` and ``fetch.py`` share one consistent implementation.
"""
VERSION = "1.8.0"
VERSION = "1.8.1"
SCHEMA_VERSION = "1.0"
USER_AGENT = f"searxng-cli/{VERSION}"
+54
View File
@@ -11,12 +11,14 @@ This module centralizes code that was previously duplicated across
* ``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 io
import logging
import sys
import urllib.error
@@ -56,6 +58,58 @@ def setup_logging(verbose: bool = False, quiet: bool = False) -> None:
# Don't let root logger add its own handler — we own the searxng namespace.
_LOG.propagate = False
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
+25 -1
View File
@@ -30,6 +30,7 @@ from common import (
apply_proxy,
build_auth_headers,
detect_charset,
force_utf8_stdout,
is_retryable_error,
resolve_auth_basic,
resolve_auth_bearer,
@@ -676,6 +677,7 @@ Examples:
args = parser.parse_args()
setup_logging(verbose=args.verbose, quiet=args.quiet)
force_utf8_stdout() # Windows: prevent GBK crash on non-ASCII chars
if not args.url.startswith(("http://", "https://")):
logger.error("Error: URL must start with http:// or https://")
@@ -711,7 +713,29 @@ Examples:
result.content, result.content_type, result.final_url,
)
except Exception as e:
logger.error(f"Error: {e}")
# 诊断信息增强:从 __cause__ 链中提取 HTTP 状态码、原始异常类型,
# 让 AI Agent 能程序化判断失败原因(404 vs 403 vs DNS 失败等),
# 而不是只看到一句 "HTTP 404 for ..."。
cause = e.__cause__
status_code = None
cause_type = type(cause).__name__ if cause else type(e).__name__
# urllib HTTPError 有 .code 属性;requests HTTPError 有 .response.status_code
if cause is not None:
status_code = (getattr(cause, "code", None) or
getattr(getattr(cause, "response", None), "status_code", None))
if status_code is None:
# 最后一道兜底:从异常消息里提取 "HTTP NNN" 模式
m = re.search(r'HTTP (\d{3})', str(e))
if m:
status_code = int(m.group(1))
diag_parts = [f"Error: {e}"]
if status_code is not None:
diag_parts.append(f"status_code={status_code}")
diag_parts.append(f"cause={cause_type}")
diag_parts.append(f"url={args.url}")
if args.no_redirect:
diag_parts.append("redirects=disabled")
logger.error(" | ".join(diag_parts))
sys.exit(1)
if final_url != args.url:
+35 -2
View File
@@ -34,6 +34,7 @@ from common import (
build_auth_headers,
classify_error,
emit_progress,
force_utf8_stdout,
resolve_auth_basic,
resolve_auth_bearer,
set_progress_enabled,
@@ -329,13 +330,39 @@ def _read_instance_file(path: Path) -> list:
return []
def _windows_appdata_config_dir() -> Path:
"""Return the Windows APPDATA config directory, or a sentinel Path if unset.
On Windows, the conventional per-user app config directory is
``%APPDATA%`` (typically ``C:\\Users\\<user>\\AppData\\Roaming``).
On POSIX, this env var is unset and we return a sentinel
``Path("/__no_appdata__")`` which never exists on disk, so the caller
can unconditionally append it to the candidate list without polluting
Linux/macOS lookups.
Note: ``Path("")`` resolves to ``.`` (current directory) on Windows,
which DOES exist — so we must use an absolute sentinel path instead.
"""
appdata = os.environ.get("APPDATA", "")
if appdata:
return Path(appdata) / "searxng-cli"
# Sentinel: absolute path that never exists. Using "/" + unlikely name
# keeps it false on both POSIX and Windows (where "/" is the drive root).
return Path("/__no_appdata__")
def resolve_instances(cli_arg: str = None) -> list:
"""Resolve instance URLs from (in priority order):
1. ``--instance`` CLI flag (comma-separated list)
2. ``SEARXNG_INSTANCE`` environment variable (comma-separated list)
3. config file: ``./searxng.toml`` → ``~/.config/searxng-cli/searxng.toml``
→ ``./instances.txt`` → ``~/.config/searxng-cli/instances.txt``
3. config file search order:
a. ``./searxng.toml``
b. ``~/.config/searxng-cli/searxng.toml``
c. ``%APPDATA%/searxng-cli/searxng.toml`` (Windows only)
d. ``./instances.txt``
e. ``~/.config/searxng-cli/instances.txt``
f. ``%APPDATA%/searxng-cli/instances.txt`` (Windows only)
Returns an empty list if no instance can be resolved.
"""
@@ -346,11 +373,14 @@ def resolve_instances(cli_arg: str = None) -> list:
if env:
return parse_instances(env)
win_dir = _windows_appdata_config_dir()
candidates = [
Path.cwd() / "searxng.toml",
Path.home() / ".config" / "searxng-cli" / "searxng.toml",
win_dir / "searxng.toml",
Path.cwd() / "instances.txt",
Path.home() / ".config" / "searxng-cli" / "instances.txt",
win_dir / "instances.txt",
]
for p in candidates:
if p.exists():
@@ -395,9 +425,11 @@ def load_config(config_path: str = None) -> dict:
except Exception as e:
logger.warning(f"Warning: cannot read config '{p}': {e}")
return {}
win_dir = _windows_appdata_config_dir()
candidates = [
Path.cwd() / "searxng.toml",
Path.home() / ".config" / "searxng-cli" / "searxng.toml",
win_dir / "searxng.toml",
]
for p in candidates:
if p.exists():
@@ -1379,6 +1411,7 @@ def main():
pre_args, _ = pre.parse_known_args()
setup_logging(verbose=pre_args.verbose, quiet=pre_args.quiet)
force_utf8_stdout() # Windows: prevent GBK crash on non-ASCII chars
# Load config defaults from --config file, else ./searxng.toml or
# ~/.config/searxng-cli/searxng.toml. Every CLI flag below can be