核心新增(面向 AI Agent 程序化使用): - 结构化错误码体系:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL classify_error() 自动分类异常,JSON 错误输出含 error_code 字段 - JSON Lines 流式输出 (--stream):每条结果独立一行,AI 可增量处理 - 进度事件 (--progress):JSON Lines 事件流到 stderr(start/cache_hit/fetch_ok/done 等) 测试补全(+154 例,覆盖全部高风险盲区): - HTML 回退搜索路径 (19) - --fetch 自动抓取 (21) - --verify 健康检查 (15) - 输出格式化 (15) - 实例解析链 (20) - 并行多实例搜索 (10) - CLI 入口与端到端 (17) - 错误码分类 (27) - 流式输出与进度事件 (10) 源码改进: - search.py: h3 内 a 标签 href 作为 url fallback,提升 SearXNG 主题兼容性 - common.py: 新增 classify_error/emit_progress/set_progress_enabled 文档同步:SKILL.md 新增 AI Agent Integration Guide 章节,README.md 更新参数与错误码表
233 lines
8.0 KiB
Python
233 lines
8.0 KiB
Python
"""Tests for the --fetch auto-fetch feature in search.py.
|
|
|
|
Covers: _is_blocked_page (CAPTCHA/bot detection heuristics), fetch_page
|
|
(ok/error/blocked paths), fetch_top_results (URL dedup, ordering,
|
|
concurrency, empty input).
|
|
"""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import search as search_mod
|
|
from search import _is_blocked_page, fetch_page, fetch_top_results
|
|
import fetch as fetch_mod
|
|
from fetch import FetchResult
|
|
|
|
|
|
# ----- _is_blocked_page -----
|
|
|
|
def test_blocked_page_detects_captcha():
|
|
assert _is_blocked_page("<html>Please complete the CAPTCHA</html>")
|
|
|
|
|
|
def test_blocked_page_detects_cloudflare():
|
|
assert _is_blocked_page("<html>Just a moment... cf-browser-verification</html>")
|
|
|
|
|
|
def test_blocked_page_detects_challenge():
|
|
assert _is_blocked_page("<html>Checking your browser before accessing</html>")
|
|
|
|
|
|
def test_blocked_page_detects_anubis():
|
|
assert _is_blocked_page("<html>anubis_challenge</html>")
|
|
|
|
|
|
def test_blocked_page_clean_html():
|
|
"""Normal HTML must not be flagged as blocked."""
|
|
assert not _is_blocked_page("<html><body><article>Real content</article></body></html>")
|
|
|
|
|
|
def test_blocked_page_empty():
|
|
assert not _is_blocked_page("")
|
|
|
|
|
|
def test_blocked_page_checks_first_2000_chars():
|
|
"""Detection only scans the first 2000 chars for performance."""
|
|
padding = "x" * 2500
|
|
html = f"<html>{padding}captcha</html>"
|
|
assert not _is_blocked_page(html)
|
|
|
|
|
|
# ----- fetch_page -----
|
|
|
|
def _mock_fetch_result(content: str, content_type="text/html",
|
|
final_url="https://example.com",
|
|
truncated=False, ua="searxng-cli/1.6.0"):
|
|
return FetchResult(
|
|
content=content,
|
|
content_type=content_type,
|
|
final_url=final_url,
|
|
truncated=truncated,
|
|
user_agent=ua,
|
|
)
|
|
|
|
|
|
def test_fetch_page_ok_html():
|
|
"""Successful HTML fetch extracts text and returns status=ok."""
|
|
html = "<html><body><article>Hello world</article></body></html>"
|
|
with patch.object(search_mod, "fetch_url",
|
|
return_value=_mock_fetch_result(html)):
|
|
r = fetch_page("https://example.com")
|
|
assert r["status"] == "ok"
|
|
assert "Hello world" in r["text"]
|
|
assert r["text_length"] > 0
|
|
assert r["truncated"] is False
|
|
|
|
|
|
def test_fetch_page_ok_non_html():
|
|
"""Non-HTML content is returned as-is without text extraction."""
|
|
with patch.object(search_mod, "fetch_url",
|
|
return_value=_mock_fetch_result(
|
|
"plain text", content_type="text/plain")):
|
|
r = fetch_page("https://example.com/file.txt")
|
|
assert r["status"] == "ok"
|
|
assert r["text"] == "plain text"
|
|
|
|
|
|
def test_fetch_page_blocked_detection():
|
|
"""CAPTCHA pages are marked as error, not ok."""
|
|
html = "<html>Please complete the CAPTCHA to continue</html>"
|
|
with patch.object(search_mod, "fetch_url",
|
|
return_value=_mock_fetch_result(html)):
|
|
r = fetch_page("https://example.com")
|
|
assert r["status"] == "error"
|
|
assert "Bot protection" in r["error"]
|
|
assert r["text"] == ""
|
|
|
|
|
|
def test_fetch_page_network_error():
|
|
"""Network exceptions return status=error with message."""
|
|
with patch.object(search_mod, "fetch_url",
|
|
side_effect=RuntimeError("HTTP 503 for url")):
|
|
r = fetch_page("https://example.com")
|
|
assert r["status"] == "error"
|
|
assert "503" in r["error"]
|
|
assert r["text"] == ""
|
|
|
|
|
|
def test_fetch_page_truncated_flag():
|
|
"""truncated=True is propagated from fetch_url."""
|
|
html = "<html>" + "x" * 100 + "</html>"
|
|
with patch.object(search_mod, "fetch_url",
|
|
return_value=_mock_fetch_result(html, truncated=True)):
|
|
r = fetch_page("https://example.com", max_size=50)
|
|
assert r["status"] == "ok"
|
|
assert r["truncated"] is True
|
|
assert r["truncated_at"] == 50
|
|
|
|
|
|
def test_fetch_page_preserves_final_url():
|
|
"""Redirect final_url is captured in the result."""
|
|
with patch.object(search_mod, "fetch_url",
|
|
return_value=_mock_fetch_result(
|
|
"<html>x</html>",
|
|
final_url="https://final.example.com/page")):
|
|
r = fetch_page("https://example.com")
|
|
assert r["final_url"] == "https://final.example.com/page"
|
|
|
|
|
|
def test_fetch_page_records_fallback_ua():
|
|
"""user_agent_used is propagated for logging/debugging."""
|
|
with patch.object(search_mod, "fetch_url",
|
|
return_value=_mock_fetch_result(
|
|
"<html>x</html>",
|
|
ua="Mozilla/5.0 fallback")):
|
|
r = fetch_page("https://example.com")
|
|
assert r["user_agent_used"] == "Mozilla/5.0 fallback"
|
|
|
|
|
|
# ----- fetch_top_results -----
|
|
|
|
def test_fetch_top_results_empty():
|
|
"""No results → empty list, no fetch attempts."""
|
|
assert fetch_top_results({"results": []}, 3) == []
|
|
|
|
|
|
def test_fetch_top_results_no_results_key():
|
|
assert fetch_top_results({}, 3) == []
|
|
|
|
|
|
def test_fetch_top_results_dedup_urls():
|
|
"""Duplicate URLs are fetched only once."""
|
|
results = {"results": [
|
|
{"url": "https://a.com"},
|
|
{"url": "https://a.com"}, # dup
|
|
{"url": "https://b.com"},
|
|
]}
|
|
fetched_urls = []
|
|
|
|
def _fake_fetch(url, **kwargs):
|
|
fetched_urls.append(url)
|
|
return {"url": url, "status": "ok", "text": "x", "text_length": 1,
|
|
"truncated": False}
|
|
|
|
with patch.object(search_mod, "fetch_page", side_effect=_fake_fetch):
|
|
out = fetch_top_results(results, 3, request_delay=0)
|
|
assert len(fetched_urls) == 2 # dedup
|
|
assert "https://a.com" in fetched_urls
|
|
assert "https://b.com" in fetched_urls
|
|
|
|
|
|
def test_fetch_top_results_limits_count():
|
|
"""Only top N unique URLs are fetched."""
|
|
results = {"results": [{"url": f"https://x{i}.com"} for i in range(10)]}
|
|
|
|
def _fake_fetch(url, **kwargs):
|
|
return {"url": url, "status": "ok", "text": "x", "text_length": 1,
|
|
"truncated": False}
|
|
|
|
with patch.object(search_mod, "fetch_page", side_effect=_fake_fetch):
|
|
out = fetch_top_results(results, 3, request_delay=0)
|
|
assert len(out) == 3
|
|
|
|
|
|
def test_fetch_top_results_preserves_order():
|
|
"""Fetched results are reordered to match original result order."""
|
|
results = {"results": [
|
|
{"url": "https://a.com"},
|
|
{"url": "https://b.com"},
|
|
{"url": "https://c.com"},
|
|
]}
|
|
|
|
def _fake_fetch(url, **kwargs):
|
|
return {"url": url, "status": "ok", "text": url[-1], "text_length": 1,
|
|
"truncated": False}
|
|
|
|
with patch.object(search_mod, "fetch_page", side_effect=_fake_fetch):
|
|
out = fetch_top_results(results, 3, request_delay=0)
|
|
urls = [f["url"] for f in out]
|
|
assert urls == ["https://a.com", "https://b.com", "https://c.com"]
|
|
|
|
|
|
def test_fetch_top_results_handles_errors():
|
|
"""A failed fetch still appears in output with status=error."""
|
|
results = {"results": [{"url": "https://ok.com"}, {"url": "https://bad.com"}]}
|
|
|
|
def _fake_fetch(url, **kwargs):
|
|
if "bad" in url:
|
|
return {"url": url, "status": "error", "error": "503",
|
|
"text": "", "text_length": 0, "truncated": False}
|
|
return {"url": url, "status": "ok", "text": "content",
|
|
"text_length": 7, "truncated": False}
|
|
|
|
with patch.object(search_mod, "fetch_page", side_effect=_fake_fetch):
|
|
out = fetch_top_results(results, 2, request_delay=0)
|
|
statuses = {f["url"]: f["status"] for f in out}
|
|
assert statuses["https://ok.com"] == "ok"
|
|
assert statuses["https://bad.com"] == "error"
|
|
|
|
|
|
def test_fetch_top_results_skips_empty_urls():
|
|
"""Results without a URL are skipped."""
|
|
results = {"results": [
|
|
{"url": ""},
|
|
{"url": "https://real.com"},
|
|
]}
|
|
|
|
def _fake_fetch(url, **kwargs):
|
|
return {"url": url, "status": "ok", "text": "x", "text_length": 1,
|
|
"truncated": False}
|
|
|
|
with patch.object(search_mod, "fetch_page", side_effect=_fake_fetch):
|
|
out = fetch_top_results(results, 3, request_delay=0)
|
|
assert len(out) == 1
|
|
assert out[0]["url"] == "https://real.com"
|