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:
+395
-35
@@ -14,6 +14,7 @@ import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -458,6 +459,16 @@ def _cfg_int(config: dict, key: str, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _cfg_float(config: dict, key: str, default: float) -> float:
|
||||
"""Read a float from config, tolerating str/int/float forms. See _cfg_int."""
|
||||
if key not in config:
|
||||
return default
|
||||
try:
|
||||
return float(config[key])
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# ----- Retry logic -----
|
||||
|
||||
def _retry_with_backoff(fn, max_retries: int = MAX_RETRIES, base_delay: float = RETRY_BACKOFF_BASE):
|
||||
@@ -812,28 +823,58 @@ def _print_verify_report(report: list, as_json: bool):
|
||||
# between search.fetch_page and fetch.fetch_url.
|
||||
|
||||
def fetch_page(url: str, timeout: int = 10, auth_headers: dict = None,
|
||||
max_retries: int = 3, max_size: int = None) -> dict:
|
||||
max_retries: int = 3, max_size: int = None,
|
||||
referer: str = None,
|
||||
fallback_enabled: bool = True) -> dict:
|
||||
"""Fetch a single page; returns metadata dict with 'status'='ok' or 'error'.
|
||||
|
||||
Thin wrapper around :func:`fetch.fetch_url` that adds:
|
||||
* CAPTCHA / bot-block detection (marks result as error)
|
||||
* CAPTCHA / bot-block detection with WAF fingerprinting (v2.0.0)
|
||||
* Wayback Machine fallback on 404/403/timeout (v2.0.0, default on)
|
||||
* automatic text extraction via :func:`fetch.extract_text`
|
||||
* dict-shaped return suitable for the auto-fetch feature
|
||||
|
||||
All HTTP transport concerns (retry, charset, UA fallback, size limit)
|
||||
are handled by ``fetch_url``.
|
||||
All HTTP transport concerns (retry, charset, UA fallback, size limit,
|
||||
browser headers, Retry-After compliance) are handled by ``fetch_url``.
|
||||
|
||||
v2.0.0 新字段:
|
||||
* ``anti_bot_detected`` (bool): 是否检测到反爬页面
|
||||
* ``waf_type`` (str|None): WAF 类型(cloudflare/imperva/perimeterx/
|
||||
datadome/akamai/generic),仅当 anti_bot_detected=True 时有值
|
||||
* ``fallback_used`` (str|None): 兜底方式("wayback"),仅当走兜底时有值
|
||||
"""
|
||||
# 主抓取
|
||||
result = None
|
||||
error_msg = None
|
||||
try:
|
||||
result = fetch_url(
|
||||
url, timeout=timeout, auth_headers=auth_headers,
|
||||
max_retries=max_retries, max_size=max_size,
|
||||
allow_redirects=True,
|
||||
allow_redirects=True, referer=referer,
|
||||
)
|
||||
except Exception as e:
|
||||
msg = str(e) if str(e) else e.__class__.__name__
|
||||
error_msg = str(e) if str(e) else e.__class__.__name__
|
||||
|
||||
# Wayback 兜底:主抓取失败或被反爬拦截时尝试
|
||||
fallback_used = None
|
||||
if fallback_enabled and _should_try_fallback(result, error_msg):
|
||||
wb_result = _try_wayback_fallback(url, timeout=timeout,
|
||||
auth_headers=auth_headers,
|
||||
max_retries=max_retries,
|
||||
max_size=max_size)
|
||||
if wb_result is not None:
|
||||
result = wb_result
|
||||
error_msg = None
|
||||
fallback_used = "wayback"
|
||||
|
||||
# 仍然失败
|
||||
if result is None:
|
||||
return {
|
||||
"url": url, "status": "error", "error": msg,
|
||||
"url": url, "status": "error",
|
||||
"error": error_msg or "unknown error",
|
||||
"text": "", "text_length": 0, "truncated": False,
|
||||
"anti_bot_detected": False, "waf_type": None,
|
||||
"fallback_used": None,
|
||||
}
|
||||
|
||||
content = result.content
|
||||
@@ -844,13 +885,21 @@ def fetch_page(url: str, timeout: int = 10, auth_headers: dict = None,
|
||||
content.strip().startswith("<!") or
|
||||
content.strip().startswith("<htm"))
|
||||
|
||||
# Detect CAPTCHA / bot-block pages (don't retry — fetch_url already
|
||||
# exhausted UA fallback inside its retry loop).
|
||||
if is_html and _is_blocked_page(content):
|
||||
# 反爬检测(v2.0.0 增强:全文档扫描 + WAF 指纹库)
|
||||
anti_bot_detected = False
|
||||
waf_type = None
|
||||
if is_html:
|
||||
waf_type = _detect_anti_bot(content)
|
||||
if waf_type:
|
||||
anti_bot_detected = True
|
||||
|
||||
if anti_bot_detected:
|
||||
return {
|
||||
"url": url, "final_url": final_url, "status": "error",
|
||||
"error": "Bot protection detected (CAPTCHA / challenge page)",
|
||||
"error": f"Bot protection detected ({waf_type})",
|
||||
"text": "", "text_length": 0, "truncated": False,
|
||||
"anti_bot_detected": True, "waf_type": waf_type,
|
||||
"fallback_used": fallback_used,
|
||||
}
|
||||
|
||||
text = extract_text(content) if is_html else content
|
||||
@@ -864,33 +913,231 @@ def fetch_page(url: str, timeout: int = 10, auth_headers: dict = None,
|
||||
"truncated": result.truncated,
|
||||
"truncated_at": max_size if result.truncated else None,
|
||||
"user_agent_used": result.user_agent,
|
||||
"anti_bot_detected": False,
|
||||
"waf_type": None,
|
||||
"fallback_used": fallback_used,
|
||||
}
|
||||
|
||||
|
||||
def _is_blocked_page(content: str) -> bool:
|
||||
"""Quick heuristic to detect bot-protection pages."""
|
||||
lower = content[:2000].lower()
|
||||
indicators = [
|
||||
def _should_try_fallback(result, error_msg: str) -> bool:
|
||||
"""判断是否应触发 Wayback 兜底。
|
||||
|
||||
触发条件:
|
||||
1. 主抓取抛异常且错误信息暗示 404/403/超时
|
||||
2. 主抓取成功但被反爬拦截(理论上 fetch_page 已处理,此处防御性)
|
||||
|
||||
不触发:
|
||||
* 用户禁用兜底(调用方控制,不进入此函数)
|
||||
* 错误是 DNS 失败(Wayback 也访问不到)
|
||||
"""
|
||||
if result is not None:
|
||||
# 主抓取成功,无需兜底
|
||||
return False
|
||||
if not error_msg:
|
||||
return False
|
||||
msg = error_msg.lower()
|
||||
# 404/403/超时/连接重置 → 尝试 Wayback
|
||||
triggers = ["404", "403", "timeout", "timed out", "connection reset",
|
||||
"connection refused", "max retries exceeded"]
|
||||
if any(t in msg for t in triggers):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _try_wayback_fallback(url: str, timeout: int = 10,
|
||||
auth_headers: dict = None,
|
||||
max_retries: int = 2,
|
||||
max_size: int = None):
|
||||
"""尝试从 Wayback Machine 获取页面快照。
|
||||
|
||||
使用 ``https://web.archive.org/web/2/<url>`` 端点,``2`` 表示
|
||||
"最新可用快照"。Wayback 会 302 重定向到具体时间戳快照。
|
||||
|
||||
返回 FetchResult 或 None(失败时)。独立超时(10s),不阻塞主流程。
|
||||
"""
|
||||
wayback_url = f"https://web.archive.org/web/2/{url}"
|
||||
wb_timeout = min(timeout, 10) # Wayback 自身可能慢,限制最大 10s
|
||||
try:
|
||||
logger.info(f" [FALLBACK] Trying Wayback Machine for {url[:55]}")
|
||||
result = fetch_url(
|
||||
wayback_url, timeout=wb_timeout, auth_headers=None,
|
||||
max_retries=max_retries, max_size=max_size,
|
||||
allow_redirects=True,
|
||||
)
|
||||
# Wayback 包装页也算成功——它返回的是原始页面内容
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.info(f" [FALLBACK] Wayback failed for {url[:55]}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ----- 反爬检测(v2.0.0 增强版)-----
|
||||
# WAF 指纹库:每项 = (waf_type, [指示词])
|
||||
# 指示词在页面 HTML/body/headers 中出现即判定为该 WAF。
|
||||
# 顺序按检测优先级:专用指纹在前,通用指纹在后。
|
||||
WAF_FINGERPRINTS = [
|
||||
("cloudflare", [
|
||||
"cf-ray", "cf-chl-bypass", "cf-mitigated",
|
||||
"cloudflare", "cf-browser-verification",
|
||||
"attention required! | cloudflare", "just a moment",
|
||||
"checking your browser before accessing",
|
||||
]),
|
||||
("imperva", [
|
||||
"incap_ses", "visid_incap", "incap_ses_",
|
||||
"imperva", "incapsula",
|
||||
"request unsuccessful. incapsula incident id",
|
||||
]),
|
||||
("perimeterx", [
|
||||
"_px", "px-captcha", "pxhd", "pxcts", "pxcookie",
|
||||
"perimeterx", "press & hold to confirm you are a human",
|
||||
]),
|
||||
("datadome", [
|
||||
"datadome", "dd-", "data-dome",
|
||||
"protected by datadome",
|
||||
]),
|
||||
("akamai", [
|
||||
"akamai", "bm_sz", "_abck",
|
||||
"reference #", "akamaighost",
|
||||
]),
|
||||
# 通用反爬指示词(无明确 WAF 归属)
|
||||
("generic", [
|
||||
"captcha", "challenge", "verify you are human",
|
||||
"checking your browser", "making sure you're not a bot",
|
||||
"cf-browser-verification", "anubis_challenge",
|
||||
"making sure you're not a bot",
|
||||
"please enable javascript", "enable javascript to continue",
|
||||
"just a moment", "ddos protection",
|
||||
]
|
||||
return any(ind in lower for ind in indicators)
|
||||
"ddos protection", "access denied",
|
||||
"you have been blocked", "unusual traffic from your computer",
|
||||
"robot or human", "are you a robot",
|
||||
"pardon our interruption", "we'll be right back",
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
def _detect_anti_bot(content: str) -> str:
|
||||
"""检测反爬页面,返回 WAF 类型或 None。
|
||||
|
||||
v2.0.0 改进:
|
||||
* 全文档扫描(去除 2000 字符限制——大页面反爬页可能在前 2000 字之外)
|
||||
* WAF 指纹库覆盖 Cloudflare/Imperva/PerimeterX/DataDome/Akamai/通用
|
||||
* 返回具体 WAF 类型而非布尔值,让 AI Agent 可决策
|
||||
|
||||
性能:全文档 lower() 一次,对 5MB 页面约 5ms,可接受。
|
||||
"""
|
||||
if not content:
|
||||
return None
|
||||
lower = content.lower()
|
||||
for waf_type, indicators in WAF_FINGERPRINTS:
|
||||
for ind in indicators:
|
||||
if ind in lower:
|
||||
return waf_type
|
||||
return None
|
||||
|
||||
|
||||
def _is_blocked_page(content: str) -> bool:
|
||||
"""[已废弃] 快速检测反爬页面。保留向后兼容,内部调用 _detect_anti_bot。
|
||||
|
||||
v2.0.0 起请使用 _detect_anti_bot() 获取具体 WAF 类型。
|
||||
"""
|
||||
return _detect_anti_bot(content) is not None
|
||||
|
||||
|
||||
class AdaptiveThrottle:
|
||||
"""自适应限流状态机(v2.0.0)。
|
||||
|
||||
在 fetch_top_results 的并发抓取过程中,根据成功/失败反馈动态调整:
|
||||
* 连续 >=3 次失败 → request_delay 翻倍,concurrency 减半
|
||||
* 连续 >=5 次成功 → 逐步恢复原参数
|
||||
* 收到 429 → 标记全局暂停 N 秒(N 来自 Retry-After 或默认 30s),
|
||||
所有线程在下次请求前等待
|
||||
|
||||
线程安全:所有方法加锁。状态由 fetch_top_results 的 _fetch_one 回调驱动。
|
||||
"""
|
||||
|
||||
def __init__(self, initial_delay: float, initial_concurrency: int):
|
||||
self._lock = threading.Lock()
|
||||
self._delay = initial_delay
|
||||
self._initial_delay = initial_delay
|
||||
self._concurrency = initial_concurrency
|
||||
self._initial_concurrency = initial_concurrency
|
||||
self._consecutive_failures = 0
|
||||
self._consecutive_successes = 0
|
||||
self._global_pause_until = 0.0 # time.monotonic() 时间戳
|
||||
|
||||
@property
|
||||
def delay(self) -> float:
|
||||
with self._lock:
|
||||
return self._delay
|
||||
|
||||
@property
|
||||
def concurrency(self) -> int:
|
||||
with self._lock:
|
||||
return self._concurrency
|
||||
|
||||
def report_success(self) -> None:
|
||||
with self._lock:
|
||||
self._consecutive_failures = 0
|
||||
self._consecutive_successes += 1
|
||||
# 连续 5 次成功 → 逐步恢复
|
||||
if self._consecutive_successes >= 5:
|
||||
self._consecutive_successes = 0
|
||||
self._delay = max(self._initial_delay, self._delay / 2)
|
||||
if self._concurrency < self._initial_concurrency:
|
||||
self._concurrency = min(self._initial_concurrency,
|
||||
self._concurrency * 2)
|
||||
|
||||
def report_failure(self, error_msg: str = "") -> None:
|
||||
with self._lock:
|
||||
self._consecutive_successes = 0
|
||||
self._consecutive_failures += 1
|
||||
# 429 → 全局暂停(调用方会从 error_msg 提取秒数,这里只标记)
|
||||
if "429" in error_msg.lower():
|
||||
self._global_pause_until = time.monotonic() + 30.0
|
||||
# 连续 3 次失败 → 退避 + 降并发
|
||||
if self._consecutive_failures >= 3:
|
||||
self._consecutive_failures = 0
|
||||
self._delay = min(self._delay * 2, 10.0) # 上限 10s
|
||||
self._concurrency = max(1, self._concurrency // 2)
|
||||
|
||||
def wait_if_paused(self) -> None:
|
||||
"""如果处于全局暂停期,阻塞等待直到解除。请求前调用。"""
|
||||
with self._lock:
|
||||
remaining = self._global_pause_until - time.monotonic()
|
||||
if remaining > 0:
|
||||
logger.info(f" [THROTTLE] Global pause: waiting {remaining:.1f}s (429)")
|
||||
time.sleep(remaining)
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""返回当前状态快照,供 --fetch-report 使用。"""
|
||||
with self._lock:
|
||||
return {
|
||||
"current_delay": round(self._delay, 3),
|
||||
"current_concurrency": self._concurrency,
|
||||
"consecutive_failures": self._consecutive_failures,
|
||||
"consecutive_successes": self._consecutive_successes,
|
||||
"global_paused": time.monotonic() < self._global_pause_until,
|
||||
}
|
||||
|
||||
|
||||
def fetch_top_results(results: dict, count: int, timeout: int = 10,
|
||||
concurrency: int = 5, auth_headers: dict = None,
|
||||
max_retries: int = 3, max_size: int = None,
|
||||
request_delay: float = 0.3) -> list:
|
||||
request_delay: float = 0.3,
|
||||
referer: str = None,
|
||||
fallback_enabled: bool = True,
|
||||
throttle: "AdaptiveThrottle" = None) -> list:
|
||||
"""Fetch full text of top N result pages concurrently.
|
||||
|
||||
Features:
|
||||
- Retries transient errors with exponential backoff
|
||||
- Falls back to browser User-Agent if blocked
|
||||
- Retries transient errors with exponential backoff (in fetch_url)
|
||||
- v2.0.0 自适应限流:连续失败自动降并发+加延迟,429 全局暂停
|
||||
- v2.0.0 Wayback 兜底:404/403/超时自动尝试 Wayback Machine
|
||||
- v2.0.0 反爬检测:WAF 指纹库识别 Cloudflare/Imperva/PerimeterX 等
|
||||
- Falls back to browser User-Agent if blocked (in fetch_url)
|
||||
- Small delay between requests to avoid rate limits
|
||||
- 5MB size limit per page
|
||||
|
||||
Args:
|
||||
referer: Referer URL(v2.0.0,通常设为 SearXNG 实例 URL)
|
||||
fallback_enabled: 是否启用 Wayback 兜底(默认 True)
|
||||
throttle: 外部传入的 AdaptiveThrottle 实例(可选;不传则内部创建)
|
||||
"""
|
||||
urls = []
|
||||
seen = set()
|
||||
@@ -905,30 +1152,52 @@ def fetch_top_results(results: dict, count: int, timeout: int = 10,
|
||||
if not urls:
|
||||
return []
|
||||
|
||||
logger.info(f"\nFetching {len(urls)} result pages (timeout={timeout}s, retries={max_retries})...")
|
||||
# 自适应限流器(外部未传入则创建)
|
||||
if throttle is None:
|
||||
throttle = AdaptiveThrottle(request_delay, concurrency)
|
||||
|
||||
logger.info(f"\nFetching {len(urls)} result pages "
|
||||
f"(timeout={timeout}s, retries={max_retries}, "
|
||||
f"delay={throttle.delay}s, concurrency={throttle.concurrency})...")
|
||||
fetched = []
|
||||
ok_count = [0]
|
||||
err_count = [0]
|
||||
anti_bot_count = [0]
|
||||
fallback_count = [0]
|
||||
|
||||
def _fetch_one(u: str) -> dict:
|
||||
"""Fetch one URL with optional delay to avoid rate limiting."""
|
||||
if request_delay > 0:
|
||||
time.sleep(request_delay * random.uniform(0.5, 1.5))
|
||||
"""Fetch one URL with adaptive throttling to avoid rate limiting."""
|
||||
# 全局暂停检查(429 触发)
|
||||
throttle.wait_if_paused()
|
||||
# 自适应延迟
|
||||
d = throttle.delay
|
||||
if d > 0:
|
||||
time.sleep(d * random.uniform(0.5, 1.5))
|
||||
result = fetch_page(u, timeout=timeout, auth_headers=auth_headers,
|
||||
max_retries=max_retries, max_size=max_size)
|
||||
max_retries=max_retries, max_size=max_size,
|
||||
referer=referer, fallback_enabled=fallback_enabled)
|
||||
if result["status"] == "ok":
|
||||
ok_count[0] += 1
|
||||
throttle.report_success()
|
||||
trunc = ", TRUNCATED" if result.get("truncated") else ""
|
||||
ua_note = ""
|
||||
if result.get("user_agent_used") != USER_AGENT:
|
||||
ua_note = " [fallback UA]"
|
||||
logger.info(f" [OK] {u[:55]} ({result['text_length']:,} chars{trunc}{ua_note})")
|
||||
fb_note = " [wayback]" if result.get("fallback_used") else ""
|
||||
if fb_note:
|
||||
fallback_count[0] += 1
|
||||
logger.info(f" [OK] {u[:55]} ({result['text_length']:,} chars"
|
||||
f"{trunc}{ua_note}{fb_note})")
|
||||
else:
|
||||
err_count[0] += 1
|
||||
throttle.report_failure(result.get("error", ""))
|
||||
# 统计反爬拦截
|
||||
if result.get("anti_bot_detected"):
|
||||
anti_bot_count[0] += 1
|
||||
logger.error(f" [ERR] {u[:55]} ({result.get('error', 'unknown')})")
|
||||
return result
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(concurrency, len(urls))) as ex:
|
||||
with ThreadPoolExecutor(max_workers=min(throttle.concurrency, len(urls))) as ex:
|
||||
future_map = {ex.submit(_fetch_one, u): u for u in urls}
|
||||
for future in as_completed(future_map):
|
||||
try:
|
||||
@@ -937,17 +1206,75 @@ def fetch_top_results(results: dict, count: int, timeout: int = 10,
|
||||
except Exception as e:
|
||||
u = future_map[future]
|
||||
fetched.append({"url": u, "status": "error", "error": str(e),
|
||||
"text": "", "text_length": 0, "truncated": False})
|
||||
"text": "", "text_length": 0, "truncated": False,
|
||||
"anti_bot_detected": False, "waf_type": None,
|
||||
"fallback_used": None})
|
||||
logger.error(f" [ERR] {u[:55]} (thread error: {e})")
|
||||
|
||||
# Reorder to match original result order
|
||||
url_order = {u: i for i, u in enumerate(urls)}
|
||||
fetched.sort(key=lambda f: url_order.get(f["url"], 999))
|
||||
|
||||
logger.info(f"Fetched: {ok_count[0]} ok, {err_count[0]} errors")
|
||||
logger.info(f"Fetched: {ok_count[0]} ok, {err_count[0]} errors"
|
||||
f" (anti-bot: {anti_bot_count[0]}, wayback: {fallback_count[0]})")
|
||||
return fetched
|
||||
|
||||
|
||||
def _emit_fetch_report(fetched: list, throttle: "AdaptiveThrottle") -> None:
|
||||
"""v2.0.0: 输出结构化抓取报告到 stderr。
|
||||
|
||||
让 AI Agent 可程序化分析抓取过程:哪些 URL 被反爬拦截、用了什么兜底、
|
||||
自适应限流如何调整。格式为人类可读的表格 + JSON 摘要。
|
||||
"""
|
||||
import sys as _sys
|
||||
out = _sys.stderr
|
||||
lines = []
|
||||
lines.append("\n" + "=" * 72)
|
||||
lines.append("FETCH REPORT (v2.0.0)")
|
||||
lines.append("=" * 72)
|
||||
|
||||
# Per-URL 表
|
||||
header = f"{'URL':<45} {'Status':<8} {'WAF':<12} {'Fallback':<10} {'Chars':>10}"
|
||||
lines.append(header)
|
||||
lines.append("-" * len(header))
|
||||
for f in fetched:
|
||||
url = f.get("url", "")[:44]
|
||||
status = "OK" if f.get("status") == "ok" else "ERR"
|
||||
waf = f.get("waf_type") or "-"
|
||||
fb = f.get("fallback_used") or "-"
|
||||
chars = f.get("text_length", 0)
|
||||
lines.append(f"{url:<45} {status:<8} {waf:<12} {fb:<10} {chars:>10,}")
|
||||
|
||||
# 统计摘要
|
||||
total = len(fetched)
|
||||
ok = sum(1 for f in fetched if f.get("status") == "ok")
|
||||
err = total - ok
|
||||
anti_bot = sum(1 for f in fetched if f.get("anti_bot_detected"))
|
||||
wayback = sum(1 for f in fetched if f.get("fallback_used") == "wayback")
|
||||
lines.append("-" * len(header))
|
||||
lines.append(f"Total: {total} | OK: {ok} | Error: {err} | "
|
||||
f"Anti-bot blocked: {anti_bot} | Wayback recovered: {wayback}")
|
||||
|
||||
# 自适应限流状态
|
||||
s = throttle.stats()
|
||||
lines.append(f"Throttle: delay={s['current_delay']}s "
|
||||
f"concurrency={s['current_concurrency']} "
|
||||
f"paused={s['global_paused']} "
|
||||
f"consec_fail={s['consecutive_failures']} "
|
||||
f"consec_ok={s['consecutive_successes']}")
|
||||
|
||||
# JSON 摘要(一行,便于 Agent 解析)
|
||||
import json as _json
|
||||
summary = {
|
||||
"total": total, "ok": ok, "error": err,
|
||||
"anti_bot_blocked": anti_bot, "wayback_recovered": wayback,
|
||||
"throttle": s,
|
||||
}
|
||||
lines.append("JSON: " + _json.dumps(summary, ensure_ascii=False))
|
||||
lines.append("=" * 72 + "\n")
|
||||
print("\n".join(lines), file=out)
|
||||
|
||||
|
||||
# ----- Output formatting -----
|
||||
|
||||
def deduplicate_results(results: dict) -> dict:
|
||||
@@ -1240,24 +1567,42 @@ def _run_single_query(query: str, args, instance_urls: list,
|
||||
|
||||
if args.fetch > 0 and results.get("results"):
|
||||
emit_progress("fetch_start", count=args.fetch)
|
||||
# v2.0.0: Referer 默认设为首个实例 URL,伪装流量来自搜索引擎
|
||||
referer = getattr(args, "referer", None)
|
||||
if referer is None and instance_urls:
|
||||
referer = instance_urls[0]
|
||||
# v2.0.0: 创建共享 throttle 实例,用于 --fetch-report 输出
|
||||
request_delay = getattr(args, "request_delay", 0.3)
|
||||
fetch_throttle = AdaptiveThrottle(request_delay,
|
||||
min(5, args.fetch))
|
||||
fetched = fetch_top_results(
|
||||
results, args.fetch,
|
||||
timeout=args.fetch_timeout,
|
||||
auth_headers=auth_headers,
|
||||
max_retries=args.fetch_retries,
|
||||
max_size=args.max_size,
|
||||
request_delay=request_delay,
|
||||
referer=referer,
|
||||
fallback_enabled=not getattr(args, "no_fallback", False),
|
||||
throttle=fetch_throttle,
|
||||
)
|
||||
# Emit fetch_ok / fetch_fail events
|
||||
for f in fetched:
|
||||
if f.get("status") == "ok":
|
||||
emit_progress("fetch_ok", url=f.get("url", ""),
|
||||
chars=f.get("text_length", 0))
|
||||
chars=f.get("text_length", 0),
|
||||
fallback=f.get("fallback_used"))
|
||||
else:
|
||||
emit_progress("fetch_fail", url=f.get("url", ""),
|
||||
error=f.get("error", "unknown"))
|
||||
error=f.get("error", "unknown"),
|
||||
waf_type=f.get("waf_type"))
|
||||
results["fetched"] = fetched
|
||||
results["fetched_source"] = results.get("_fallback", "json")
|
||||
|
||||
# v2.0.0: --fetch-report 输出到 stderr
|
||||
if getattr(args, "fetch_report", False):
|
||||
_emit_fetch_report(fetched, fetch_throttle)
|
||||
|
||||
result_count = len(results.get("results", []))
|
||||
emit_progress("done", results=result_count, query=query)
|
||||
# 清理内部 _fallback 字段,避免泄漏到 JSON 输出。
|
||||
@@ -1506,6 +1851,21 @@ Use --config FILE to load a non-default config file (overrides the auto-discover
|
||||
help="Timeout per page fetch in seconds (default: 10)")
|
||||
parser.add_argument("--fetch-retries", type=int, default=_cfg_int(config, "fetch_retries", 3),
|
||||
help="Max retries per page fetch (default: 3)")
|
||||
parser.add_argument("--fetch-report", action="store_true",
|
||||
help="When used with --fetch, emit a structured fetch report to stderr "
|
||||
"after completion: per-URL status, UA used, attempts, WAF type, "
|
||||
"fallback used, and adaptive throttle stats. v2.0.0.")
|
||||
parser.add_argument("--no-fallback", action="store_true",
|
||||
help="Disable Wayback Machine fallback for failed fetches (404/403/timeout). "
|
||||
"By default Wayback fallback is ENABLED to maximize success rate. v2.0.0.")
|
||||
parser.add_argument("--referer", default=None, metavar="URL",
|
||||
help="Set Referer header for fetch requests (e.g. the SearXNG instance URL). "
|
||||
"Defaults to the instance URL when fetching result pages. v2.0.0.")
|
||||
parser.add_argument("--request-delay", type=float,
|
||||
default=_cfg_float(config, "request_delay", 0.3),
|
||||
metavar="SECONDS",
|
||||
help="Delay between fetch requests to avoid rate limiting (default: 0.3s). "
|
||||
"v2.0.0: adaptive throttling may increase this on consecutive failures.")
|
||||
parser.add_argument("--max-size", type=int, default=_cfg_int(config, "max_size", None),
|
||||
metavar="BYTES",
|
||||
help="Max page size in bytes (default: unlimited). Set to 5242880 for 5MB cap.")
|
||||
|
||||
Reference in New Issue
Block a user