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 个测试全部通过
This commit is contained in:
2026-08-01 21:34:57 +08:00
parent ea7a60a460
commit 28ff7c0a48
12 changed files with 2003 additions and 75 deletions
+205 -1
View File
@@ -113,6 +113,7 @@ def force_utf8_stdout() -> None:
# 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
@@ -121,15 +122,218 @@ RETRY_BACKOFF_BASE = 1.5 # seconds; exponential backoff + jitter
# 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
# Browser-like UA strings for fallback when the searxng-cli UA is blocked.
#
# v2.0.0 扩充至 12 个:覆盖 Chrome/Edge/Firefox × Windows/macOS/Linux
# 每个都是较新版本(131/130/129),避免被识别为过时浏览器。
# 顺序固定以便 get_ua_for_domain() 的 hash 选择可复现。
FALLBACK_UAS = [
# Chrome 131 — Windows / macOS / Linux
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
# Edge 131 — Windows / macOS
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
# Firefox 133 — Windows / macOS / Linux
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) "
"Gecko/20100101 Firefox/133.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) "
"Gecko/20100101 Firefox/133.0",
"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0",
# Chrome 130 — Windows / macOS (上一个版本,应对 131 被针对性识别)
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
# Chrome 129 — Windows / Linux
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
]
def _ua_index_for_domain(domain: str, pool_size: int) -> int:
"""为域名确定性选择 UA 池索引。
用 ``hashlib.sha256`` 而非内置 ``hash()``,因为后者对字符串做了
随机化(PYTHONHASHSEED),跨进程不可复现。SHA-256 保证同一域名
永远映射到同一索引,跨进程一致——这对调试和日志分析至关重要。
"""
import hashlib
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 = {}
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 上降低集体封禁风险。
"""
if user_agent:
return user_agent
import urllib.parse as _up
try:
domain = _up.urlparse(url).netloc.lower()
if not domain:
return FALLBACK_UAS[0]
except Exception:
return FALLBACK_UAS[0]
if domain in _domain_ua_cache:
return _domain_ua_cache[domain]
idx = _ua_index_for_domain(domain, len(FALLBACK_UAS))
ua = FALLBACK_UAS[idx]
_domain_ua_cache[domain] = ua
return ua
def reset_domain_ua_cache() -> None:
"""清空 per-domain UA 缓存。测试用。"""
_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"
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": "gzip, deflate" if is_firefox else "gzip, deflate, br",
"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
import re
m = re.search(r"Chrome/(\d+)", user_agent)
ver = m.group(1) if m else "131"
not_a_brand = '"Not_A Brand";v="99"' if ver != "99" else '"Not/A)Brand";v="99"'
headers["Sec-Ch-Ua"] = f'"{not_a_brand}", "Chromium";v="{ver}", "Google Chrome";v="{ver}"'
if is_edge:
# Edge 的品牌标识
headers["Sec-Ch-Ua"] = headers["Sec-Ch-Ua"].rstrip('"') + f'", "Microsoft Edge";v="{ver}"'
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 会卡死进程。
"""
import random
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.