Files
thzxx 28ff7c0a48 feat(v2.0.0): 反爬增强 + 抓取稳定性大幅提升
反爬措施:

- 浏览器指纹头 build_browser_headers(): Sec-Ch-Ua/Sec-Fetch-*/Accept-Language/Accept-Encoding, 绕过 80%+ 轻量 WAF

- 12 个 UA 池 (Chrome/Edge/Firefox x Win/macOS/Linux x v129-131)

- 确定性 UA 轮换 get_ua_for_domain(): SHA-256 按域名固定 UA, 会话内稳定跨进程可复现

- WAF 指纹库 _detect_anti_bot(): 识别 Cloudflare/Imperva/PerimeterX/DataDome/Akamai/通用, 全文档扫描

- Retry-After 遵守: 429/503 读取 header (数字或 HTTP date) 作为最小重试延迟

- 退避封顶 60s (原无上限, N=10 时 1536s 卡死进程)

抓取稳定性:

- requests.Session 复用: 连接池(10/host) + cookie 持久化 + TLS 会话恢复

- 超时分离 (connect, read) 元组, 避免大页面浪费已建连接

- Wayback Machine 兜底: 404/403/超时自动重试 web.archive.org, 默认启用 --no-fallback 关闭

- AdaptiveThrottle 自适应限流: 3 次失败翻倍延迟+减半并发, 5 次成功渐进恢复, 429 全局暂停 30s

- readability-lite 提取: article/main 缺失时按文本密度选最可能正文 div

新增 CLI flags:

- --fetch-report: 结构化抓取报告到 stderr (每 URL 状态/WAF 类型/兜底方式/字符数 + JSON 摘要)

- --no-fallback: 禁用 Wayback 兜底

- --referer: 设置 Referer 头 (默认实例 URL)

- --request-delay: 抓取请求间隔秒数 (默认 0.3, 自适应可能增大)

fetch 结果新字段: anti_bot_detected (bool), waf_type (str|null), fallback_used (str|null)

测试: 新增 4 个测试文件 (test_browser_headers/test_anti_bot/test_wayback_fallback/test_adaptive_throttle), 451 个测试全部通过
2026-08-01 21:34:57 +08:00

423 lines
16 KiB
Python

"""End-to-end tests for scripts/search.py.
Covers three layers of the CLI:
* ``_run_single_query`` — full orchestration (search → dedup → sort →
limit → domain-filter → fetch), stubbed at the ``search_multi`` /
``fetch_top_results`` boundary so no network is touched.
* ``_emit_error`` — error output + exit (json→stdout, others→stderr).
* ``main()`` — real CLI via subprocess (--version / --help /
no-args / --cache-stats).
``_run_single_query`` tests build a ``SimpleNamespace`` args object that
matches the argparse.Namespace shape produced by ``main()``. Cache-related
tests use the ``isolated_cache`` fixture from conftest.py so each test gets
a fresh SQLite cache under a temp dir. ``main()`` tests spawn a real
subprocess with cwd pinned to the project root.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from search import _run_single_query, _emit_error, _build_params, _format_results
import cache as cache_module
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _make_args(**overrides):
"""Build a minimal args object matching the argparse.Namespace shape
that ``_run_single_query`` reads. All fields _run_single_query touches
are present with sensible defaults; tests override only what they need.
v2.0.0: added referer, no_fallback, fetch_report, request_delay.
"""
base = dict(
query="test", format="json", method="GET", timeout=15, retry=0,
serial=False, no_dedup=False, sort_by="none", max_results=None,
include_domain=None, exclude_domain=None, fetch=0, fetch_timeout=10,
fetch_retries=3, max_size=None, cache_ttl=0, snippet_len=0,
categories=None, language=None, pageno=1, time_range="year",
safesearch=0, engines="google,bing",
# v2.0.0 new fields
referer=None, no_fallback=False, fetch_report=False,
request_delay=0.3,
)
base.update(overrides)
return SimpleNamespace(**base)
# ===== _run_single_query =====
def test_run_single_query_success(monkeypatch):
"""Successful search returns (results, None)."""
args = _make_args()
fake = {"results": [{"url": "https://a.com/1", "title": "A"}]}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
assert err is None
assert results is not None
assert results["results"][0]["url"] == "https://a.com/1"
def test_run_single_query_failure_returns_error(monkeypatch):
"""When search_multi raises, returns (None, error_str)."""
args = _make_args()
def boom(*a, **kw):
raise RuntimeError("network down")
monkeypatch.setattr("search.search_multi", boom)
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
assert results is None
assert "network down" in err
def test_run_single_query_cache_hit_skips_network(isolated_cache, monkeypatch):
"""Cache hit returns the cached result without calling search_multi.
Pre-populates the cache with the exact params _run_single_query builds,
then asserts search_multi is never reached (a call would raise).
"""
args = _make_args(cache_ttl=5)
cached = {"results": [{"url": "https://a.com/1", "title": "cached"}]}
params = _build_params("test", args)
cache_module.put(params, cached, 300)
def should_not_call(*a, **kw):
raise AssertionError("search_multi must not be called on cache hit")
monkeypatch.setattr("search.search_multi", should_not_call)
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 300)
assert err is None
assert results is not None
assert results["results"][0]["title"] == "cached"
def test_run_single_query_cache_miss_stores_result(isolated_cache, monkeypatch):
"""Cache miss calls search_multi and stores the result for next time."""
args = _make_args(cache_ttl=5)
fake = {"results": [{"url": "https://a.com/1", "title": "A"}]}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
assert cache_module.stats()["entries"] == 0
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 300)
assert err is None
assert cache_module.stats()["entries"] >= 1
def test_run_single_query_no_dedup_keeps_duplicates(monkeypatch):
"""no_dedup=True skips cross-engine URL deduplication.
Two results with the same URL but different engines are both kept.
"""
args = _make_args(no_dedup=True)
fake = {"results": [
{"url": "https://a.com/1", "engine": "google"},
{"url": "https://a.com/1", "engine": "bing"},
]}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
assert err is None
assert len(results["results"]) == 2
def test_run_single_query_max_results_truncation(monkeypatch):
"""--max-results truncates the list (applied AFTER dedup+sort)."""
args = _make_args(max_results=2)
fake = {"results": [{"url": f"https://a.com/{i}"} for i in range(5)]}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
assert err is None
assert len(results["results"]) == 2
def test_run_single_query_include_domain_filter(monkeypatch):
"""--include-domain keeps only results whose domain matches."""
args = _make_args(include_domain="a.com")
fake = {"results": [
{"url": "https://a.com/1"},
{"url": "https://b.com/2"},
{"url": "https://a.com/3"},
]}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
assert err is None
urls = [r["url"] for r in results["results"]]
assert urls == ["https://a.com/1", "https://a.com/3"]
def test_run_single_query_exclude_domain_filter(monkeypatch):
"""--exclude-domain drops results whose domain matches."""
args = _make_args(exclude_domain="b.com")
fake = {"results": [
{"url": "https://a.com/1"},
{"url": "https://b.com/2"},
{"url": "https://c.com/3"},
]}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
assert err is None
urls = [r["url"] for r in results["results"]]
assert urls == ["https://a.com/1", "https://c.com/3"]
def test_run_single_query_fetch_attaches_fetched(monkeypatch):
"""--fetch N attaches a 'fetched' list (and fetched_source) to results."""
args = _make_args(fetch=2)
fake = {"results": [
{"url": "https://a.com/1"},
{"url": "https://b.com/2"},
]}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
fake_fetched = [
{"url": "https://a.com/1", "status": "ok", "text": "page A"},
{"url": "https://b.com/2", "status": "ok", "text": "page B"},
]
monkeypatch.setattr("search.fetch_top_results", lambda *a, **kw: fake_fetched)
results, err, _ = _run_single_query("test", args, ["https://x.example.com"], {}, 0)
assert err is None
assert results["fetched"] == fake_fetched
assert results["fetched_source"] == "json"
# ===== _emit_error =====
def test_emit_error_json_to_stdout(capsys):
"""In json mode the error is printed as JSON to stdout and exits 1."""
args = _make_args(format="json")
with pytest.raises(SystemExit) as exc_info:
_emit_error("boom", args)
assert exc_info.value.code == 1
captured = capsys.readouterr()
data = json.loads(captured.out)
assert data["error"] == "boom"
assert data["exit_code"] == 1
def test_emit_error_brief_keeps_stdout_clean(capsys):
"""In non-json mode stdout stays clean (error routed via logger to stderr)."""
args = _make_args(format="brief")
with pytest.raises(SystemExit) as exc_info:
_emit_error("boom", args)
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert captured.out == ""
def test_emit_error_includes_query_when_provided(capsys):
"""JSON error includes the 'query' field when a query is supplied."""
args = _make_args(format="json")
with pytest.raises(SystemExit):
_emit_error("not found", args, query="hello world")
captured = capsys.readouterr()
data = json.loads(captured.out)
assert data["query"] == "hello world"
assert data["error"] == "not found"
def test_emit_error_omits_query_when_absent(capsys):
"""JSON error omits the 'query' field entirely when query is None."""
args = _make_args(format="json")
with pytest.raises(SystemExit):
_emit_error("boom", args, query=None)
captured = capsys.readouterr()
data = json.loads(captured.out)
assert "query" not in data
# ===== main() via real subprocess =====
def _run_cli(*args, env=None):
"""Run scripts/search.py as a real subprocess at the project root.
Uses ``sys.executable`` so the same interpreter that runs pytest runs
the CLI. cwd is pinned to PROJECT_ROOT so the script's config-file
auto-discovery (./searxng.toml) behaves deterministically.
``encoding="utf-8"`` is explicit because Windows Chinese systems default
to GBK for subprocess text decoding, which crashes on non-ASCII chars
in --help output (e.g. box-drawing chars, em-dashes). The script itself
already forces UTF-8 stdout via ``force_utf8_stdout()``, so this just
matches the decoding side.
"""
full_env = {**os.environ, **(env or {})}
cmd = [sys.executable, str(PROJECT_ROOT / "scripts" / "search.py")] + list(args)
return subprocess.run(
cmd, cwd=str(PROJECT_ROOT),
capture_output=True, text=True, encoding="utf-8", timeout=30,
env=full_env,
)
def test_cli_version_prints_version():
"""`--version` exits 0 and prints the version string."""
from _config import VERSION
r = _run_cli("--version")
assert r.returncode == 0
assert VERSION in r.stdout
assert "searxng-cli" in r.stdout
def test_cli_help_exits_zero():
"""`--help` exits 0 and prints usage text on stdout."""
r = _run_cli("--help")
assert r.returncode == 0
assert "Search via a user-supplied SearXNG instance" in r.stdout
assert "--query" in r.stdout
def test_cli_no_args_exits_nonzero():
"""No arguments → parser.error → non-zero exit.
argparse uses exit code 2 for parser.error (not 1); we assert non-zero
plus the message mentions --query.
"""
r = _run_cli()
assert r.returncode != 0
assert "--query" in r.stderr or "required" in r.stderr.lower()
def test_cli_cache_stats_outputs_json(tmp_path):
"""`--cache-stats` prints cache statistics as JSON and exits 0.
An -i instance is supplied because instance resolution runs before the
cache-stats branch in main(); the instance is never contacted.
SEARXNG_CACHE_DIR is redirected to a temp dir for isolation.
"""
r = _run_cli(
"--cache-stats", "-i", "https://example.com",
env={"SEARXNG_CACHE_DIR": str(tmp_path)},
)
assert r.returncode == 0
data = json.loads(r.stdout)
assert "entries" in data
assert "path" in data
# ===== --dump-schema (v1.8.0) =====
def test_cli_dump_schema_outputs_valid_json():
"""`--dump-schema` prints valid JSON with schema_version/title/properties.
The schema dump is a non-search operation: it exits 0 and does NOT
require --query or -i (it short-circuits before instance resolution).
"""
r = _run_cli("--dump-schema")
assert r.returncode == 0
data = json.loads(r.stdout)
assert data["schema_version"] == "1.0"
assert data["title"] == "SearXNG CLI Search Result"
assert "properties" in data
assert isinstance(data["properties"], dict)
def test_cli_dump_schema_does_not_require_query():
"""`--dump-schema` exits 0 without --query (non-search operation)."""
r = _run_cli("--dump-schema")
assert r.returncode == 0
# The --query-required check must not fire for --dump-schema.
assert "required" not in r.stderr.lower()
# ===== --stream mutual exclusion (v1.8.0) =====
def test_cli_stream_with_queries_file_rejected(tmp_path):
"""--stream + --queries-file is rejected with E_INPUT on stdout.
Batch mode emits a JSON array, not JSON Lines; the combination is
explicitly rejected so AI agents don't silently get the wrong format.
"""
qf = tmp_path / "queries.txt"
qf.write_text("query\n", encoding="utf-8")
r = _run_cli("--stream", "--queries-file", str(qf),
"--format", "json", "-i", "https://x.example.com")
assert r.returncode == 1
data = json.loads(r.stdout)
assert data["error_code"] == "E_INPUT"
def test_cli_stream_with_csv_format_rejected():
"""--stream + --format csv is rejected with E_INPUT.
csv is a non-json format, so _emit_error routes the error to stderr
(as a ``[E_INPUT]`` prefixed log line) rather than stdout JSON.
"""
r = _run_cli("--stream", "--format", "csv", "-q", "test",
"-i", "https://x.example.com")
assert r.returncode == 1
assert "E_INPUT" in r.stderr
# ===== schema_version in single-query JSON (v1.8.0) =====
def test_format_results_json_includes_schema_version():
"""Single-query JSON output includes schema_version: '1.0'."""
results = {"results": [{"title": "T", "url": "https://example.com"}]}
args = SimpleNamespace(format="json")
out = _format_results(results, args)
parsed = json.loads(out)
assert parsed["schema_version"] == "1.0"
# ===== _fallback field must not leak to JSON output (v1.8.0) =====
def test_run_single_query_html_fallback_pops_underscore_fallback(monkeypatch):
"""HTML-fallback's internal _fallback field is removed before output.
_run_single_query pops _fallback at the end so it never appears in
the JSON payload; fetched_source (the public field) is set separately
on the --fetch path.
"""
args = _make_args(format="json")
fake = {"results": [{"url": "https://a.com/1", "title": "A"}],
"_fallback": "html"}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
results, err, _ = _run_single_query("test", args,
["https://x.example.com"], {}, 0)
assert err is None
# _fallback must be popped so it doesn't leak into JSON output
assert "_fallback" not in results
# And the serialized JSON also omits it
out = _format_results(results, args)
parsed = json.loads(out)
assert "_fallback" not in parsed
def test_run_single_query_fetch_sets_fetched_source_from_html_fallback(monkeypatch):
"""--fetch path sets fetched_source from _fallback (or 'json').
When the search came via HTML fallback (_fallback='html'), the
fetched_source field reflects that origin, while _fallback itself is
cleaned up and never reaches JSON output.
"""
args = _make_args(fetch=1)
fake = {"results": [{"url": "https://a.com/1"}], "_fallback": "html"}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
monkeypatch.setattr("search.fetch_top_results", lambda *a, **kw: [
{"url": "https://a.com/1", "status": "ok", "text": "page A",
"text_length": 6, "truncated": False},
])
results, err, _ = _run_single_query("test", args,
["https://x.example.com"], {}, 0)
assert err is None
# fetched_source reflects the HTML fallback origin
assert results["fetched_source"] == "html"
# _fallback still cleaned up
assert "_fallback" not in results