初始化

This commit is contained in:
2026-07-18 16:13:52 +08:00
parent c0070f0a5c
commit fe8b417ab6
75 changed files with 12898 additions and 1 deletions
+434
View File
@@ -0,0 +1,434 @@
"""Crawl4AI 异步新闻抓取引擎
海外服务器运行,约束:内存 ≤ 2GB,串行抓取,单源 ≤ 2 小时。
所有业务参数从 configs/system.yaml 的 crawler 节读取。
"""
import asyncio
import gc
import hashlib
import json
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin
import psutil
import yaml
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
from crawler.models import ArticleItem, CrawlResult, SourceConfig
from crawler.utils import get_news_day
logger = logging.getLogger(__name__)
# ── 从配置文件加载常量 ──────────────────────────────────
def _load_crawler_config() -> dict:
"""从 system.yaml 读取 crawler 配置节(含 Profile 覆盖)"""
try:
from crawler.config import load_system_config
cfg = load_system_config()
return cfg.get("crawler", {})
except Exception:
logger.warning("读取 system.yaml 失败,使用默认值")
return {}
_cfg = _load_crawler_config()
MAX_MEMORY_MB = int(_cfg.get("max_memory_mb", 1800))
ARTICLE_DELAY_SEC = float(_cfg.get("article_delay_sec", 3.0))
PAGE_TIMEOUT_MS = int(_cfg.get("page_timeout_sec", 45)) * 1000
SOURCE_TIMEOUT_SEC = int(_cfg.get("source_timeout_sec", 7200))
_VIEWPORT_WIDTH = int(_cfg.get("viewport_width", 1024))
_VIEWPORT_HEIGHT = int(_cfg.get("viewport_height", 768))
_USER_AGENT = _cfg.get(
"user_agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36",
)
_HEADFUL = bool(_cfg.get("headful", False))
_XVFB_DISPLAY = _cfg.get("xvfb_display", ":99")
# ── 代理配置 ──────────────────────────────────────────
def _load_proxy_config() -> dict:
"""从 system.yaml 读取 proxy 配置节(含 Profile 覆盖)"""
try:
from crawler.config import load_system_config
cfg = load_system_config()
return cfg.get("proxy", {})
except Exception:
pass
return {}
_proxy_cfg = _load_proxy_config()
PROXY_ENABLED = bool(_proxy_cfg.get("enabled", False))
PROXY_URL = _proxy_cfg.get("url", "socks5://127.0.0.1:1080")
# ── 工具函数 ──────────────────────────────────────────
def compute_url_hash(url: str) -> str:
"""计算 URL 的 SHA256 前 16 个字符作为短文件名"""
return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
def _get_memory_mb() -> float:
"""获取当前进程内存使用量(MB"""
proc = psutil.Process(os.getpid())
return proc.memory_info().rss / (1024 * 1024)
def _check_memory(source_id: str) -> None:
"""检查内存,超限时警告"""
mem = _get_memory_mb()
if mem > MAX_MEMORY_MB:
logger.warning("[%s] ⚠️ 内存使用 %.0f MB 超过上限 %.0f MB,触发 GC",
source_id, mem, MAX_MEMORY_MB)
gc.collect()
mem_after = _get_memory_mb()
logger.info("[%s] GC 后内存: %.0f MB (回收 %.0f MB)",
source_id, mem_after, mem - mem_after)
# ── 浏览器配置 ──────────────────────────────────────────
def _make_browser_config(source: SourceConfig) -> BrowserConfig:
"""浏览器配置:根据 anti_bot_mode 和系统配置选择策略
- None: 标准轻量 headless(默认)
- "stealth": 反检测 headless(隐藏自动化特征)
- "headful": 非 headless 模式(有头浏览器,最像真人)
代理:如果 system.yaml proxy.enabled=true,浏览器流量走 SOCKS5 代理。
headful 模式自动设置 DISPLAY 环境变量(支持 xvfb)。
"""
mode = source.anti_bot_mode
# headless 判定:
# - 系统级 _HEADFUL=true → 强制有头(覆盖所有 source 模式)
# - source mode="headful" → 有头
# - 其他 → headless
if _HEADFUL:
headless = False # 系统级强制 headful
elif mode == "headful":
headless = False
else:
headless = True
# 基础反检测参数
extra_args = [
"--disable-dev-shm-usage",
"--disable-gpu",
"--no-sandbox",
]
# 反检测参数:stealth 模式或系统级 headful 都启用
if mode == "stealth" or _HEADFUL:
extra_args += [
"--disable-blink-features=AutomationControlled",
"--disable-features=IsolateOrigins,site-per-process",
]
# ── SOCKS5 代理 ──────────────────────────────────
if PROXY_ENABLED and PROXY_URL:
extra_args.append(f"--proxy-server={PROXY_URL}")
logger.info("🌐 浏览器代理已启用: %s", PROXY_URL)
# ── headful 模式:设置虚拟显示器 ──────────────────
if not headless:
display = os.environ.get("DISPLAY", "")
if not display:
os.environ["DISPLAY"] = _XVFB_DISPLAY
logger.info("🖥️ headful 模式: DISPLAY=%s", _XVFB_DISPLAY)
return BrowserConfig(
browser_type="chromium",
headless=headless,
viewport_width=_VIEWPORT_WIDTH,
viewport_height=_VIEWPORT_HEIGHT,
verbose=False,
text_mode=headless, # headful 模式下不禁用图片(更像真人)
light_mode=headless,
user_agent=_USER_AGENT,
extra_args=extra_args,
)
def _make_run_config(source: SourceConfig) -> CrawlerRunConfig:
"""抓取运行时配置"""
return CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
page_timeout=PAGE_TIMEOUT_MS,
wait_until="domcontentloaded",
scan_full_page=False,
simulate_user=True,
override_navigator=True,
)
def _extract_domain(url: str) -> str:
"""从 URL 提取域名(去掉 www 前缀)。"""
from urllib.parse import urlparse
host = urlparse(url).hostname or ""
return host.removeprefix("www.").lower()
# ── 链接提取 ──────────────────────────────────────────
async def _extract_article_urls(
crawler: AsyncWebCrawler,
source: SourceConfig,
) -> list[str]:
"""从首页抓取符合 article_url_pattern 的文章链接(增量:跳过已抓取过的 URL)"""
config = _make_run_config(source)
try:
result = await crawler.arun(url=source.homepage, config=config)
except Exception as e:
logger.error("抓取首页失败 [%s] %s: %s", source.id, source.homepage, e)
return []
if not result.success:
logger.error("首页返回失败 [%s]: %s", source.id, result.error_message)
return []
html = result.html or ""
pattern = re.compile(r'href=["\']([^"\']*?)["\']', re.IGNORECASE)
raw_urls: list[str] = pattern.findall(html)
article_url_pattern = re.compile(source.article_url_pattern, re.IGNORECASE)
base_without_fragment = source.homepage.split("#")[0]
base_domain = _extract_domain(source.homepage)
# 非内容类扩展名
_SKIP_EXT = re.compile(
r"\.(png|ico|gif|jpg|jpeg|svg|webp|css|js|xml|json|rss|pdf|zip|woff2?|ttf|eot)"
r"([?#]|$)",
re.IGNORECASE,
)
# 非文章技术路径
_SKIP_PATH = re.compile(
r"/(manifest|robots|sitemap|_next/static|__nextjs_|favicon)"
r"[/.]",
re.IGNORECASE,
)
article_urls: list[str] = []
for raw in raw_urls:
raw_stripped = raw.strip()
if raw_stripped.startswith("#") or not raw_stripped:
continue
if raw_stripped.lower().startswith("javascript:"):
continue
full = urljoin(source.homepage, raw_stripped)
full_clean = full.split("#")[0]
# 过滤非内容类 URL
if _SKIP_EXT.search(full_clean):
continue
if _SKIP_PATH.search(full_clean):
continue
# 过滤非同域链接(广告、CDN 等)
if _extract_domain(full_clean) != base_domain:
continue
if full_clean.rstrip("/") == base_without_fragment.rstrip("/"):
continue
if article_url_pattern.search(full_clean):
article_urls.append(full_clean)
# 本批去重
seen: set[str] = set()
unique: list[str] = []
for u in article_urls:
if u not in seen:
seen.add(u)
unique.append(u)
# 增量过滤:读取已抓取的 url_hash,跳过已存在 URL
today = get_news_day()
index_path = Path(f"data/raw/{source.id}/{today}/index.jsonl")
existing_hashes: set[str] = set()
if index_path.exists():
with open(index_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
existing_hashes.add(item.get("url_hash", ""))
except json.JSONDecodeError:
continue
filtered: list[str] = []
skipped = 0
for u in unique:
uh = compute_url_hash(u)
if uh in existing_hashes:
skipped += 1
logger.debug("[%s] 跳过已抓取: %s", source.id, u[:80])
continue
filtered.append(u)
if len(filtered) >= source.max_articles_per_run:
break
if skipped > 0:
logger.info("[%s] 增量过滤: 跳过 %d 篇已抓取,剩余 %d 篇待抓取",
source.id, skipped, len(filtered))
logger.info("[%s] 从首页提取到 %d 个文章链接(去重后 %d,增量后 %d",
source.id, len(article_urls), len(unique), len(filtered))
return filtered
# ── 单篇抓取 ──────────────────────────────────────────
async def _crawl_single_article(
crawler: AsyncWebCrawler,
url: str,
source: SourceConfig,
index: int,
total: int,
) -> ArticleItem:
"""抓取单篇文章(串行模式)"""
url_hash = compute_url_hash(url)
now = datetime.now()
today = get_news_day()
article = ArticleItem(
source_id=source.id,
source_name=source.name,
url=url,
url_hash=url_hash,
title="",
crawl_time=now.isoformat(),
html_path="",
status="failed",
)
try:
config = _make_run_config(source)
result = await crawler.arun(url=url, config=config)
if not result.success:
article.error = result.error_message or "Unknown error"
logger.warning("[%s] [%d/%d] 抓取失败: %s",
source.id, index, total, article.error[:100])
return article
# 提取标题
article.title = (result.metadata.get("title") if result.metadata else "") or ""
# 保存输出
out_dir = Path(f"data/raw/{source.id}/{today}")
out_dir.mkdir(parents=True, exist_ok=True)
# HTML
html_path = out_dir / f"{url_hash}.html"
if result.html:
html_path.write_text(result.html, encoding="utf-8")
article.html_path = str(html_path)
# Markdown
md_path = out_dir / f"{url_hash}.md"
md_content = ""
if hasattr(result, "markdown") and result.markdown:
md_content = str(result.markdown)
md_path.write_text(md_content, encoding="utf-8")
article.md_path = str(md_path)
article.word_count = len(md_content.split()) if md_content else 0
article.status = "success"
# 每篇都打日志方便追踪进度
mem_mb = _get_memory_mb()
logger.info("[%s] [%d/%d] ✅ %s (%d words, %.0f MB)",
source.id, index, total, article.title[:50], article.word_count, mem_mb)
except Exception as e:
article.error = str(e)
logger.error("[%s] [%d/%d] 抓取异常: %s", source.id, index, total, e)
return article
# ── 单源抓取 ──────────────────────────────────────────
async def crawl_source(source: SourceConfig) -> CrawlResult:
"""抓取单个新闻源:首页 → 串行抓取每篇文章
约束:内存 ≤ 2GB,超时 ≤ 2 小时
"""
start_time = datetime.now()
result = CrawlResult(
source_id=source.id,
source_name=source.name,
start_time=start_time.isoformat(),
)
mem_start = _get_memory_mb()
logger.info("━━━ 开始 [%s] %s (内存: %.0f MB) ━━━", source.id, source.name, mem_start)
browser_config = _make_browser_config(source)
try:
async with asyncio.timeout(SOURCE_TIMEOUT_SEC):
async with AsyncWebCrawler(config=browser_config) as crawler:
# 1. 首页提取链接
article_urls = await _extract_article_urls(crawler, source)
result.total_found = len(article_urls)
if not article_urls:
logger.warning("[%s] 未提取到任何文章链接", source.id)
result.end_time = datetime.now().isoformat()
return result
# 2. 串行抓取每篇文章
total = len(article_urls)
for i, url in enumerate(article_urls, 1):
_check_memory(source.id)
article = await _crawl_single_article(
crawler, url, source, index=i, total=total,
)
if article.status == "success":
result.total_success += 1
else:
result.total_failed += 1
result.articles.append(article)
# 冷却间隔
if i < total:
await asyncio.sleep(ARTICLE_DELAY_SEC)
except TimeoutError:
logger.error("[%s] ⏰ 超时 %.0f 秒(上限 %d 秒),中止",
source.id, (datetime.now() - start_time).total_seconds(),
SOURCE_TIMEOUT_SEC)
result.error = "Source timeout"
except Exception as e:
logger.exception("[%s] 抓取过程异常: %s", source.id, e)
result.error = str(e)
# 主动回收
gc.collect()
result.end_time = datetime.now().isoformat()
elapsed = (datetime.now() - start_time).total_seconds()
mem_end = _get_memory_mb()
logger.info(
"[%s] 完成: 发现 %d / 成功 %d / 失败 %d,耗时 %.1f 秒,内存 %.0f%.0f MB",
source.id, result.total_found, result.total_success, result.total_failed,
elapsed, mem_start, mem_end,
)
return result