Files
searxng-use-cli/scripts/cache.py
T
thzxx fb9b2af45f feat(v1.8.0): 稳定性修复 + AI Agent 体验增强
稳定性修复:

- 修复 cache.py SQLite 连接泄漏(contextlib.closing 包装)

- 修复 fetch.py requests stream=True 连接泄漏(try/finally resp.close())

- RETRYABLE_STATUS 新增 403,激活 UA fallback 切换逻辑

- --cache-stats 移至实例解析前,无需实例即可查询

- classify_error 从错误消息提取 HTTP 状态码,正确分类 E_AUTH/E_RATE_LIMIT

- --stream 与 --queries-file 互斥检查,违规报 E_INPUT

- batch 退出码语义统一(0=有结果 / 1=全部错误 / 2=全部空结果)

AI Agent 体验增强:

- 错误码体系完善:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL

- recovery_hint 恢复提示字段,AI Agent 可程序化决策恢复策略

- stream 模式新增 error 事件类型(含 error_code + recovery_hint)

- 进度事件扩展:instance_try/instance_ok/instance_fail

- batch 模式统一 schema(status 字段区分 success/failed)

- JSON 输出含 schema_version 字段确保版本兼容

测试与文档:

- 测试覆盖:330 -> 352

- SKILL.md / README.md 同步更新
2026-08-01 19:02:44 +08:00

171 lines
6.0 KiB
Python

"""SQLite-backed result cache for searxng-cli.
Avoids re-hitting the SearXNG instance for identical queries within a
configurable TTL. Cache key is a SHA-256 of the normalized search params
(query + engines + categories + language + time_range + safesearch +
pageno + method), so different parameter combinations get separate entries.
Storage location (in priority order):
1. ``$SEARXNG_CACHE_DIR`` env var (directory; ``cache.db`` is created inside)
2. ``~/.cache/searxng-cli/cache.db`` (XDG-style; on Windows this resolves
to ``C:\\Users\\<user>\\.cache\\searxng-cli\\cache.db``)
Uses WAL journal mode for better read concurrency. Entries expire lazily
on read; :func:`clear` removes all rows. Schema is created on first use.
Design notes:
* Only the search result dict is cached — fetched page content is NOT,
because it is large and changes independently of the search result set.
* The cache key excludes auth headers and timeouts (transient concerns)
so two callers with the same query + params share an entry.
* All operations swallow ``sqlite3.Error`` and degrade gracefully — a
cache failure must never break a search.
"""
import contextlib
import hashlib
import json
import os
import sqlite3
import time
from pathlib import Path
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "searxng-cli"
def _cache_path() -> Path:
"""Resolve the cache database path from env var or default location."""
env = os.environ.get("SEARXNG_CACHE_DIR")
if env:
return Path(env) / "cache.db"
return DEFAULT_CACHE_DIR / "cache.db"
def _connect(path: Path):
"""Open a connection with WAL mode and ensure the schema exists.
Returns a :class:`contextlib.closing` wrapper so ``with _connect(...) as conn:``
closes the connection on exit. ``sqlite3.Connection.__exit__`` only commits /
rolls back the transaction — it does **not** call ``close()``, which leaks
file descriptors across many cache operations (especially under
``--queries-file`` + ``--cache-ttl`` batch runs).
"""
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path), timeout=10)
# WAL allows concurrent readers alongside a single writer, which matters
# when --fetch spawns parallel page fetches that might also touch the cache.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS search_cache (
key TEXT PRIMARY KEY,
created_at REAL NOT NULL,
ttl_seconds INTEGER NOT NULL,
payload TEXT NOT NULL
)
"""
)
conn.commit()
return contextlib.closing(conn)
def _make_key(params: dict) -> str:
"""Build a stable cache key from search params.
Only params that affect the result set are included; transient fields
(auth, timeout, format) are excluded so the same logical query hits the
same cache entry regardless of output formatting.
"""
# Whitelist the params that actually change what SearXNG returns.
# fmt: off
relevant = (
"q", "categories", "language", "pageno",
"time_range", "safesearch", "engines", "method",
)
# fmt: on
normalized = {k: params[k] for k in relevant if params.get(k)}
raw = json.dumps(normalized, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def get(params: dict, ttl_seconds: int):
"""Return cached result if within TTL, else None.
``ttl_seconds`` is the caller's current TTL setting. If the stored
entry was written with a longer TTL, the caller's shorter TTL wins
(so reducing --cache-ttl takes effect immediately without a clear).
"""
if ttl_seconds <= 0:
return None
key = _make_key(params)
try:
with _connect(_cache_path()) as conn:
row = conn.execute(
"SELECT payload, created_at, ttl_seconds FROM search_cache "
"WHERE key = ?",
(key,),
).fetchone()
if row is None:
return None
payload, created_at, stored_ttl = row
effective_ttl = min(ttl_seconds, stored_ttl)
if time.time() - created_at > effective_ttl:
return None
return json.loads(payload)
except sqlite3.Error:
return None
except (ValueError, json.JSONDecodeError):
# Corrupt payload — treat as miss
return None
def put(params: dict, result: dict, ttl_seconds: int) -> None:
"""Store a result with the given TTL. Silently no-ops on TTL<=0 or error."""
if ttl_seconds <= 0:
return
key = _make_key(params)
payload = json.dumps(result, ensure_ascii=False)
try:
with _connect(_cache_path()) as conn:
conn.execute(
"INSERT OR REPLACE INTO search_cache "
"(key, created_at, ttl_seconds, payload) VALUES (?, ?, ?, ?)",
(key, time.time(), ttl_seconds, payload),
)
conn.commit()
except sqlite3.Error:
pass
def clear() -> int:
"""Remove all cache entries. Returns count deleted, or 0 on error."""
try:
with _connect(_cache_path()) as conn:
cur = conn.execute("DELETE FROM search_cache")
conn.commit()
return cur.rowcount
except sqlite3.Error:
return 0
def stats() -> dict:
"""Return cache statistics (entry count, age range, path)."""
path = _cache_path()
try:
with _connect(path) as conn:
row = conn.execute(
"SELECT COUNT(*), MIN(created_at), MAX(created_at) "
"FROM search_cache"
).fetchone()
count, oldest, newest = row
return {
"entries": count or 0,
"oldest_created_at": oldest,
"newest_created_at": newest,
"path": str(path),
"size_bytes": path.stat().st_size if path.exists() else 0,
}
except sqlite3.Error as e:
return {"entries": 0, "error": str(e), "path": str(path), "size_bytes": 0}