Files

436 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""
爬取增强引擎 v7.7.0 (crawl_enhancer.py)
在既有基础设施(http_utils / anti_block_utils / browser_pro)之上提供
智能多级爬取管道与批量并发爬取:
1. SmartFetcher —— 三级降级抓取:
API 直连(api_registry 挖掘到的接口)→ HTTP → 浏览器兜底
并对结果做失败分类:ok / soft_blocked / empty / timeout / error
2. AdaptiveCrawlPolicy —— v7.7 自适应策略:
失败早停(风控止损)/ 连续失败并发降级 / 全域沦陷提前终止 / 耗时统计
3. batch_smart_crawl —— 线程池并发批量爬取:
域名限速 + URL 去重(可持久化)+ 域名健康度跟踪 + 成功率统计
用法:
from scripts.crawl_enhancer import SmartFetcher, batch_smart_crawl
fr = SmartFetcher().fetch("https://example.com/news")
print(fr.status, fr.text_len, fr.source)
stats = batch_smart_crawl(urls, concurrency=4,
dedup_file="data/seen_urls.json")
print(stats["stats"])
"""
from __future__ import annotations
import json
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field, asdict
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse
try:
from .http_utils import http_get
from .anti_block_utils import (DomainRateLimiter, DomainHealthTracker,
UrlDeduplicator, detect_block)
except ImportError: # 脚本直跑
from http_utils import http_get # type: ignore
from anti_block_utils import (DomainRateLimiter, DomainHealthTracker, # type: ignore
UrlDeduplicator, detect_block)
# ── 状态分类 ───────────────────────────────────────────────────
STATUS_OK = "ok"
STATUS_SOFT_BLOCKED = "soft_blocked" # 200 但内容是反爬页/验证码页
STATUS_EMPTY = "empty" # 内容为空或过短
STATUS_TIMEOUT = "timeout"
STATUS_ERROR = "error"
_OK_STATUSES = {STATUS_OK}
# v7.7 自适应策略默认值(均可经 batch_smart_crawl 参数覆盖)
EARLY_STOP_MIN_SAMPLES = 8 # 早停判定最少样本数(避免冷启动误判)
EARLY_STOP_FAIL_RATE = 0.85 # 失败率达到此值且无成功 → 提前终止剩余任务
@dataclass
class FetchResult:
"""单次抓取的标准结果。"""
url: str
status: str = STATUS_ERROR
html: str = ""
text_len: int = 0
source: str = "" # api / http / browser
elapsed: float = 0.0
error: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
@property
def ok(self) -> bool:
return self.status == STATUS_OK
def to_dict(self) -> Dict[str, Any]:
d = asdict(self)
d.pop("html", None) # 大字段不进统计
return d
def classify_response(html: Optional[str], min_text_len: int = 200) -> str:
"""把一次 HTTP/浏览器响应分类为抓取状态(纯函数,可单测)。"""
if html is None:
return STATUS_ERROR
text = html.strip()
if not text:
return STATUS_EMPTY
block = detect_block(200, None, text[:500])
if block:
return STATUS_SOFT_BLOCKED
if len(text) < min_text_len:
# 短页面:可能是反爬壳页,也可能确实是小接口响应
if _looks_like_block_shell(text):
return STATUS_SOFT_BLOCKED
if text.lstrip()[:1] in ("{", "["):
return STATUS_OK # JSON 短响应也算有效
return STATUS_EMPTY
return STATUS_OK
_BLOCK_SHELL_RE = re.compile(
r"(验证码|安全验证|访问被拒|请勿频繁访问|人机验证|滑动验证|"
r"captcha|verify|access denied|forbidden)", re.IGNORECASE)
def _looks_like_block_shell(text: str) -> bool:
return bool(_BLOCK_SHELL_RE.search(text))
class SmartFetcher:
"""多级降级抓取器:API 直连 → HTTP → 浏览器。
所有外部依赖(http_fetcher / browser_fetcher / api_lookup)均可注入,
便于离线测试;默认实现延迟绑定到项目内模块。
"""
def __init__(self,
http_fetcher: Optional[Callable[..., Any]] = None,
browser_fetcher: Optional[Callable[[str], Optional[str]]] = None,
api_lookup: Optional[Callable[[str], Optional[Dict]]] = None,
min_text_len: int = 200):
self.http_fetcher = http_fetcher or self._default_http_fetch
self.browser_fetcher = browser_fetcher
self.api_lookup = api_lookup or self._default_api_lookup
self.min_text_len = min_text_len
# ── 默认实现 ──────────────────────────────────────────────
@staticmethod
def _default_http_fetch(url: str, timeout: int = 20):
return http_get(url, timeout=timeout)
@staticmethod
def _default_api_lookup(url: str) -> Optional[Dict[str, Any]]:
"""在已挖掘的 API 注册表中找同域接口(懒加载避免循环依赖)。"""
try:
from .browser_pro import ApiRegistry
except ImportError:
try:
from browser_pro import ApiRegistry # type: ignore
except ImportError:
return None
try:
domain = urlparse(url).netloc.lower()
if not domain:
return None
hits = ApiRegistry().search(domain=domain)
return hits[0] if hits else None
except Exception:
return None
@staticmethod
def _default_browser_fetch(url: str) -> Optional[str]:
try:
try:
from .browser_scraper import browser_fetch
except ImportError:
from browser_scraper import browser_fetch # type: ignore
return browser_fetch(url)
except Exception:
return None
def _http_fetch(self, url: str):
"""单次 HTTP 抓取并分类(返回 (状态, 正文, 错误信息))。"""
try:
resp = self.http_fetcher(url)
if resp is None:
return STATUS_ERROR, "", "http_get returned None"
html = getattr(resp, "text", "") or ""
return classify_response(html, self.min_text_len), html, ""
except TimeoutError as e:
return STATUS_TIMEOUT, "", str(e)
except Exception as e:
msg = str(e)
if "timed out" in msg.lower() or "timeout" in msg.lower():
return STATUS_TIMEOUT, "", msg
return STATUS_ERROR, "", msg
# ── 主流程 ────────────────────────────────────────────────
def fetch(self, url: str, use_api: bool = True,
use_browser: bool = True) -> FetchResult:
start = time.monotonic()
result = FetchResult(url=url)
# 1) API 直连(挖掘到的接口优先)
if use_api:
api = None
try:
api = self.api_lookup(url)
except Exception:
api = None
if api and api.get("url"):
try:
resp = self.http_fetcher(api["url"], timeout=15)
html = getattr(resp, "text", None) or ""
status = classify_response(html, min_text_len=50)
if status == STATUS_OK:
result.status = STATUS_OK
result.html = html
result.text_len = len(html)
result.source = "api"
result.extra = {"api_url": api["url"]}
result.elapsed = time.monotonic() - start
return result
except Exception:
pass
# 2) HTTP 抓取
status, html, err = self._http_fetch(url)
if status == STATUS_OK:
result.status, result.html, result.source = STATUS_OK, html, "http"
result.text_len = len(html)
result.elapsed = time.monotonic() - start
return result
# 3) 浏览器兜底(仅对"拿不到/被拦"做兜底,超时不重试浏览器避免叠加等待)
if use_browser and status in (STATUS_SOFT_BLOCKED, STATUS_EMPTY,
STATUS_ERROR):
try:
bhtml = (self.browser_fetcher or self._default_browser_fetch)(url)
bstat = classify_response(bhtml, self.min_text_len)
if bstat == STATUS_OK:
result.status, result.html, result.source = (
STATUS_OK, bhtml, "browser")
result.text_len = len(bhtml)
result.elapsed = time.monotonic() - start
return result
except Exception as e:
err = err or f"browser: {e}"
result.status, result.error = status, err
result.elapsed = time.monotonic() - start
return result
class AdaptiveCrawlPolicy:
"""v7.7 自适应爬取策略(线程安全)。
- 早停:累计 ≥min_samples 且成功率 0 → 判定整批被风控,终止剩余任务;
后续若恢复成功则自动解除(如限速窗口过期)。
- 并发降级:连续 ≥downgrade_after 次失败 → 建议降并发(由调用方执行)。
- 全域沦陷:所有出现过的域名都被健康跟踪器禁用 → 提前终止。
"""
def __init__(self, min_samples: int = EARLY_STOP_MIN_SAMPLES,
fail_rate: float = EARLY_STOP_FAIL_RATE,
downgrade_after: int = 4):
self.min_samples = max(1, min_samples)
self.fail_rate = fail_rate
self.downgrade_after = downgrade_after
self._lock = threading.Lock()
self.ok = 0
self.fail = 0
self.consecutive_fail = 0
self.downgrades = 0
self.early_stopped = False
self.stop_reason = ""
def record(self, fr: FetchResult, seen_domains: set) -> None:
with self._lock:
if fr.ok:
self.ok += 1
self.consecutive_fail = 0
self.early_stopped = False # 恢复成功 → 解除早停
self.stop_reason = ""
else:
self.fail += 1
self.consecutive_fail += 1
total = self.ok + self.fail
if (total >= self.min_samples and self.ok == 0):
self.early_stopped = True
self.stop_reason = "all_failed"
def should_stop(self, health=None, seen_domains=None) -> bool:
with self._lock:
if self.early_stopped:
return True
if health is not None and seen_domains:
try:
if all(not health.is_available(d) for d in seen_domains):
with self._lock:
self.early_stopped = True
self.stop_reason = "domains_disabled"
return True
except Exception:
pass
return False
def should_downgrade(self) -> bool:
with self._lock:
if self.consecutive_fail >= self.downgrade_after:
self.consecutive_fail = 0
self.downgrades += 1
return True
return False
@staticmethod
def degraded_concurrency(current: int) -> int:
return max(1, current // 2)
def snapshot(self) -> Dict[str, Any]:
with self._lock:
return {"ok": self.ok, "fail": self.fail,
"downgrades": self.downgrades,
"early_stopped": self.early_stopped,
"stop_reason": self.stop_reason}
def batch_smart_crawl(urls: List[str],
concurrency: int = 4,
dedup_file: Optional[str] = None,
rate_map: Optional[Dict[str, float]] = None,
use_browser: bool = False,
fetcher: Optional[SmartFetcher] = None,
progress: Optional[Callable[[int, int, str], None]] = None,
adaptive: bool = True,
early_stop_min_samples: int = EARLY_STOP_MIN_SAMPLES,
early_stop_fail_rate: float = EARLY_STOP_FAIL_RATE,
) -> Dict[str, Any]:
"""并发批量智能爬取。
Args:
urls: 待爬 URL 列表
concurrency: 并发数(建议 ≤5,避免触发风控)
dedup_file: 可选,URL 去重持久化文件(跨进程)
rate_map: 域名限速表,如 {"cls.cn": 2.0, "default": 1.0}
use_browser: 是否允许浏览器兜底(默认关,批量场景控制资源)
fetcher: 可注入自定义 SmartFetcher(测试用)
progress: 进度回调 (done, total, url)
adaptive: v7.7 自适应策略(失败早停/并发降级/全域沦陷终止)
early_stop_min_samples / early_stop_fail_rate: 早停参数(保留兼容)
Returns:
{"results": [FetchResult...], "stats": {...}}
"""
fetcher = fetcher or SmartFetcher()
limiter = DomainRateLimiter(rate_map or {"default": 1.0})
health = DomainHealthTracker()
dedup = UrlDeduplicator(dedup_file) if dedup_file else None
policy = AdaptiveCrawlPolicy(min_samples=early_stop_min_samples,
fail_rate=early_stop_fail_rate) if adaptive else None
targets: List[str] = []
skipped_dup = 0
for u in urls:
if dedup and not dedup.is_new(u):
skipped_dup += 1
continue
targets.append(u)
results: List[FetchResult] = []
done = 0
skipped_stopped = 0
seen_domains: set = set()
wall_start = time.monotonic()
def _work(url: str) -> FetchResult:
# v7.7 死域快速跳过:域名已被健康跟踪器禁用时不再消耗请求与限速等待
domain = urlparse(url).netloc.lower()
if not health.is_available(domain):
return FetchResult(url=url, status=STATUS_ERROR,
error="domain disabled by health tracker")
limiter.wait(url)
fr = fetcher.fetch(url, use_browser=use_browser)
if fr.ok:
health.record_success(domain)
else:
health.record_failure(domain)
return fr
# v7.7 分批提交:每批 = 当前并发数,批间检查早停/降级,避免一次性提交全部
pool_size = max(1, min(concurrency, 8))
queue = list(targets)
with ThreadPoolExecutor(max_workers=pool_size) as pool:
while queue:
if policy and policy.should_stop(health, seen_domains):
skipped_stopped += len(queue)
break
batch, queue = queue[:pool_size], queue[pool_size:]
futures = {pool.submit(_work, u): u for u in batch}
for fut in as_completed(futures):
try:
fr = fut.result()
except Exception as e:
fr = FetchResult(url=futures[fut], status=STATUS_ERROR,
error=str(e))
seen_domains.add(urlparse(fr.url).netloc.lower())
results.append(fr)
done += 1
if policy:
policy.record(fr, seen_domains)
if policy.should_downgrade() and pool_size > 1:
pool_size = AdaptiveCrawlPolicy.degraded_concurrency(pool_size)
pool._max_workers = pool_size # 动态收紧在飞上限(在飞任务不中断)
if progress:
try:
progress(done, len(targets), fr.url)
except Exception:
pass
by_status: Dict[str, int] = {}
by_domain: Dict[str, Dict[str, int]] = {}
for fr in results:
by_status[fr.status] = by_status.get(fr.status, 0) + 1
dom = urlparse(fr.url).netloc.lower()
d = by_domain.setdefault(dom, {"ok": 0, "fail": 0})
d["ok" if fr.ok else "fail"] += 1
ok_n = by_status.get(STATUS_OK, 0)
stats = {
"total": len(urls),
"fetched": len(results),
"skipped_dup": skipped_dup,
"skipped_stopped": skipped_stopped, # v7.7 早停未执行数
"ok": ok_n,
"success_rate": round(ok_n / len(results), 3) if results else 0.0,
"elapsed": round(time.monotonic() - wall_start, 2), # v7.7 耗时统计
"avg_elapsed": round(sum(r.elapsed for r in results) / len(results), 3)
if results else 0.0,
"by_status": by_status,
"by_domain": by_domain,
"adaptive": policy.snapshot() if policy else None,
}
return {"results": results, "stats": stats}
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("用法: python scripts/crawl_enhancer.py <url> [url2 ...]")
sys.exit(1)
out = batch_smart_crawl(sys.argv[1:], concurrency=2)
for fr in out["results"]:
print(f"[{fr.status:<12}] {fr.source or '-':<8} "
f"{fr.text_len:>6}B {fr.url}")
print(json.dumps(out["stats"], ensure_ascii=False, indent=1))