初始化

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
View File
+59
View File
@@ -0,0 +1,59 @@
"""系统配置加载 + Profile 覆盖。
读取 configs/system.yaml,如果设置了 EN_NEWS_PROFILE 环境变量,
则加载 configs/profiles/{name}.yaml 并深度合并覆盖。
"""
import logging
import os
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
SYSTEM_CONFIG_PATH = Path("configs/system.yaml")
PROFILES_DIR = Path("configs/profiles")
def _deep_merge(base: dict, override: dict) -> dict:
"""深度合并两个字典,override 的值覆盖 base。"""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def load_system_config() -> dict[str, Any]:
"""加载系统配置,自动应用 EN_NEWS_PROFILE 覆盖。
Returns:
合并后的完整配置字典(已应用 profile 覆盖)
"""
if not SYSTEM_CONFIG_PATH.exists():
raise FileNotFoundError(f"系统配置文件不存在: {SYSTEM_CONFIG_PATH}")
with open(SYSTEM_CONFIG_PATH, encoding="utf-8") as f:
config: dict[str, Any] = yaml.safe_load(f) or {}
# ── Profile 覆盖 ──────────────────────────────────
profile_name = os.environ.get("EN_NEWS_PROFILE", "")
if profile_name:
profile_path = PROFILES_DIR / f"{profile_name}.yaml"
if profile_path.exists():
logger.info("📋 加载 Profile: %s (%s)", profile_name, profile_path)
with open(profile_path, encoding="utf-8") as f:
profile_cfg = yaml.safe_load(f) or {}
config = _deep_merge(config, profile_cfg)
logger.info("📋 Profile 覆盖已应用: proxy=%s, headful=%s, max_memory=%s",
config.get("proxy", {}).get("enabled"),
config.get("crawler", {}).get("headful"),
config.get("crawler", {}).get("max_memory_mb"))
else:
logger.warning("⚠️ Profile 文件不存在: %s", profile_path)
return config
+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
+65
View File
@@ -0,0 +1,65 @@
"""加载和管理新闻源配置"""
import logging
from pathlib import Path
import yaml
from crawler.models import SourceConfig
logger = logging.getLogger(__name__)
# 默认配置文件路径
DEFAULT_SOURCES_PATH = Path("configs/sources.yaml")
def load_sources(
config_path: Path | None = None,
) -> tuple[list[SourceConfig], dict]:
"""从 YAML 加载新闻源配置
Args:
config_path: 配置文件路径,默认 configs/sources.yaml
Returns:
(启用的源列表, settings dict)
"""
path = config_path or DEFAULT_SOURCES_PATH
if not path.exists():
raise FileNotFoundError(f"新闻源配置文件不存在: {path}")
with open(path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
if not raw or "sources" not in raw:
raise ValueError(f"配置文件格式错误,缺少 'sources' 字段: {path}")
settings = raw.get("settings", {})
sources: list[SourceConfig] = []
for item in raw["sources"]:
if not isinstance(item, dict):
logger.warning("跳过非字典格式的源配置: %s", item)
continue
if not item.get("enabled", True):
logger.info("跳过已禁用的源: %s (%s)", item.get("id"), item.get("name"))
continue
try:
source = SourceConfig(**item)
sources.append(source)
except Exception as e:
logger.error("解析源配置失败: %s - %s", item.get("id"), e)
logger.info("成功加载 %d 个启用的新闻源(共 %d 个)", len(sources), len(raw["sources"]))
return sources, settings
def get_source_by_id(source_id: str, sources: list[SourceConfig]) -> SourceConfig | None:
"""按 ID 查找源"""
for s in sources:
if s.id == source_id:
return s
return None
+68
View File
@@ -0,0 +1,68 @@
"""爬虫数据模型"""
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel, Field
class SourceConfig(BaseModel):
"""单个新闻源配置"""
id: str
name: str
enabled: bool = True
homepage: str
article_url_pattern: str
js_render: bool = False
max_articles_per_run: int = 30
rss_url: str | None = None # RSS/Atom feed URL(优先使用,绕过反爬)
anti_bot_mode: str | None = None # "stealth" | "headful" | None(反爬策略)
@property
def output_dir(self) -> Path:
"""按日期组织的输出目录"""
today = datetime.now().strftime("%Y%m%d")
return Path(f"data/raw/{self.id}/{today}")
class ArticleItem(BaseModel):
"""单篇已抓取的文章元数据"""
source_id: str
source_name: str
url: str
url_hash: str
title: str
crawl_time: str # ISO 8601
publish_time: str = "" # ISO 8601RSS 源可提取,网页源由 extractor 补充
html_path: str # 相对路径,如 data/raw/reuters/20260621/abc123.html
md_path: str = "" # Crawl4AI 生成的 Markdown 路径
word_count: int = 0
status: str = "success" # success | failed
error: str = ""
class CrawlResult(BaseModel):
"""单次抓取结果统计"""
source_id: str
source_name: str
total_found: int = 0
total_success: int = 0
total_failed: int = 0
articles: list[ArticleItem] = Field(default_factory=list)
start_time: str = ""
end_time: str = ""
error: str = ""
class PipelineStats(BaseModel):
"""一次完整抓取管道的统计"""
start_time: str = ""
end_time: str = ""
sources_crawled: int = 0
sources_failed: int = 0
total_articles: int = 0
results: list[CrawlResult] = Field(default_factory=list)
+126
View File
@@ -0,0 +1,126 @@
"""抓取编排器:串行调度多个新闻源的抓取。支持 RSS 优先、stealth/headful 回退。"""
import asyncio
import logging
from datetime import datetime
from crawler.crawler import crawl_source
from crawler.loader import load_sources
from crawler.models import CrawlResult, PipelineStats, SourceConfig
from crawler.rss_crawler import crawl_rss_source
from crawler.storage import write_index_jsonl
logger = logging.getLogger(__name__)
async def _crawl_single_source_with_storage(source: SourceConfig) -> CrawlResult:
"""抓取单个源并写入存储。
优先级:
1. 有 rss_url → RSS 抓取(绕过反爬)
2. RSS 失败或未配置 → Web 抓取(含 stealth/headful
"""
result = await crawl_source_smart(source)
if result.total_success > 0:
write_index_jsonl(result)
return result
async def crawl_source_smart(source: SourceConfig) -> CrawlResult:
"""智能抓取:RSS 优先,Web 回退。
RSS 结果判定:
- total_success > 0 → 有新文章,直接返回
- total_found > 0, success=0 → 增量全部跳过(正常,不浪费 Web 回退)
- total_found == 0 → RSS 真·空结果
- error 非空 → RSS 失败(网络/解析错),回退 Web
Web 回退仅在 RSS 失败或未配置时执行。
"""
rss_url = getattr(source, "rss_url", None)
# ── 策略 1: RSS(同步,不消耗浏览器资源)──
if rss_url:
logger.info("[%s] 尝试 RSS 抓取: %s", source.id, rss_url)
rss_result = crawl_rss_source(source)
if rss_result.total_success > 0:
logger.info("[%s] ✅ RSS 成功: %d", source.id, rss_result.total_success)
return rss_result
if rss_result.error:
# RSS 抓取本身失败(网络错/解析错)→ 回退 Web
logger.warning("[%s] RSS 失败 (%s),回退 Web 抓取 (mode=%s)",
source.id, rss_result.error, source.anti_bot_mode)
elif rss_result.total_found > 0:
# RSS 成功但全部增量跳过 → 正常,不浪费 Web 资源
logger.info("[%s] RSS 无新文章(%d 条全部已抓取),跳过 Web 回退",
source.id, rss_result.total_found)
return rss_result
else:
# total_found == 0RSS 返回空
logger.warning("[%s] RSS 返回空,回退 Web 抓取 (mode=%s)",
source.id, source.anti_bot_mode)
# ── 策略 2/3: Web 抓取(stealth / headful)──
mode = source.anti_bot_mode or "standard"
logger.info("[%s] 开始 Web 抓取 (mode=%s)", source.id, mode)
web_result = await crawl_source(source)
return web_result
async def crawl_all_sources(
source_filter: str | None = None,
) -> PipelineStats:
"""串行抓取所有启用的新闻源(海外服务器内存约束,逐个执行)
Args:
source_filter: 可选,只抓取指定 source_id
Returns:
PipelineStats 总体统计
"""
sources, _settings = load_sources()
if source_filter:
sources = [s for s in sources if s.id == source_filter]
if not sources:
raise ValueError(f"未找到启用的源: {source_filter}")
logger.info("══════ 开始串行抓取 %d 个新闻源 ══════", len(sources))
start_time = datetime.now()
stats = PipelineStats(start_time=start_time.isoformat(), sources_crawled=0)
# 串行执行每个源
for source in sources:
result = await _crawl_single_source_with_storage(source)
if isinstance(result, Exception):
logger.error("源抓取异常: %s", result)
stats.sources_failed += 1
else:
stats.sources_crawled += 1
stats.total_articles += result.total_success
stats.results.append(result)
if result.error:
stats.sources_failed += 1
stats.end_time = datetime.now().isoformat()
elapsed = (datetime.now() - start_time).total_seconds()
logger.info(
"══════ 抓取完成: %d/%d 源成功,共 %d 篇文章,耗时 %.1f 秒 ══════",
stats.sources_crawled - stats.sources_failed,
stats.sources_crawled,
stats.total_articles,
elapsed,
)
return stats
def run_crawl_sync(source_filter: str | None = None) -> PipelineStats:
"""同步包装器,供 CLI 调用"""
return asyncio.run(crawl_all_sources(source_filter=source_filter))
+393
View File
@@ -0,0 +1,393 @@
"""RSS/Atom Feed 抓取模块。
用于有 RSS 服务的新闻源(如 MarketWatch),绕过网页反爬。
支持 RSS 2.0、Atom、Google News RSS 三种格式。
Google News RSS: 处理标题去来源后缀、链接为 Google 跳转 URL。
"""
import hashlib
import logging
import re
from datetime import datetime, timezone
from html import unescape
from pathlib import Path
from typing import Any
from urllib.parse import urljoin
from xml.etree import ElementTree
import httpx
from crawler.models import ArticleItem, CrawlResult, SourceConfig
from crawler.utils import get_news_day
logger = logging.getLogger(__name__)
# ── HTML 标签清洗 ──────────────────────────────────────
def _strip_html(text: str) -> str:
"""去除 HTML 标签,保留纯文本。"""
if not text:
return ""
text = unescape(text)
return re.sub(r"<[^>]+>", "", text).strip()
# ── Namespace ──────────────────────────────────────────
def _ns(tag: str) -> str:
"""Atom namespace helper。"""
return f"{{http://www.w3.org/2005/Atom}}{tag}"
def _compute_url_hash(url: str) -> str:
return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
# 非内容类扩展名 — RSS 项也可能包含静态资源
_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,
)
def _is_valid_article_url(url: str, source: SourceConfig) -> bool:
"""校验 RSS 条目 URL 是否为有效文章链接。
过滤规则:
1. 静态资源(JS/CSS/图片/字体/数据文件)
2. 技术路径(manifest/_next/static/robots
3. 源 article_url_pattern 不匹配
4. 域名不一致(RSS 跨站污染,如 Yahoo Finance RSS 混入 sports/tech
"""
if _SKIP_EXT.search(url):
logger.debug("RSS 跳过静态资源: %s", url[:80])
return False
if _SKIP_PATH.search(url):
logger.debug("RSS 跳过技术路径: %s", url[:80])
return False
# 源级 article_url_pattern 校验
pattern = getattr(source, "article_url_pattern", "")
if pattern:
try:
if not re.search(pattern, url, re.IGNORECASE):
logger.debug("RSS 跳过不匹配 article_url_pattern: %s", url[:80])
return False
except re.error:
pass # 正则异常不阻塞
# 域名一致性校验(RSS 跨站污染防护)
# 例外: Google News RSS 的链接是 news.google.com 跳转 URL,跳过域名校验
rss_url = getattr(source, "rss_url", "")
if rss_url and "news.google.com" not in rss_url:
from urllib.parse import urlparse
item_domain = (urlparse(url).hostname or "").removeprefix("www.")
hp_domain = (urlparse(source.homepage).hostname or "").removeprefix("www.")
if hp_domain and item_domain:
if item_domain != hp_domain and not item_domain.endswith("." + hp_domain):
logger.debug(
"RSS 跳过跨站 URL: %s (domain=%s, expected=%s)",
url[:80], item_domain, hp_domain,
)
return False
return True
def _parse_date(date_str: str | None) -> str:
"""尝试解析常见日期格式,返回 ISO 8601 字符串。"""
if not date_str:
return datetime.now().isoformat()
formats = [
"%a, %d %b %Y %H:%M:%S %z", # RFC 2822
"%a, %d %b %Y %H:%M:%S %Z",
"%Y-%m-%dT%H:%M:%S%z", # ISO 8601
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d",
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt).isoformat()
except ValueError:
continue
return date_str
# ── Google News RSS 标题清洗 ────────────────────────────
_KNOWN_SOURCES = [
"Reuters", "WSJ", "Bloomberg", "CNBC", "Financial Times",
"MarketWatch", "Barron's", "Yahoo Finance", "Investing.com",
"Seeking Alpha", "The Economist", "ForexLive", "ZeroHedge",
]
def _clean_google_news_title(title: str) -> str:
"""Google News RSS 标题格式: 'Article Title - SourceName' → 去掉来源后缀。"""
for src in _KNOWN_SOURCES:
suffix = f" - {src}"
if title.endswith(suffix):
return title[: -len(suffix)].strip()
# 通用回退:最后一个 " - " 之后可能是来源
last_dash = title.rfind(" - ")
if last_dash > 0:
suffix = title[last_dash + 3:]
if len(suffix) < 30 and not suffix.startswith("http"):
return title[:last_dash].strip()
return title
# ── RSS/Atom 解析 ─────────────────────────────────────
def _extract_rss_items(xml_text: str) -> list[dict[str, Any]]:
"""从 RSS/Atom XML 中提取文章条目。
自动识别:RSS 2.0、Google News RSS、Atom。
"""
# 清理可能的 BOM
if xml_text.startswith(""):
xml_text = xml_text[1:]
root = ElementTree.fromstring(xml_text)
items: list[dict[str, Any]] = []
# 检测是否为 Google News RSS
first_item = root.find(".//item")
is_google_news = False
if first_item is not None:
source_el = first_item.find("source")
if source_el is not None and source_el.text:
is_google_news = True
# RSS 2.0 / Google News RSS: channel/item
for item in root.iter("item"):
title = _strip_html(_text(item, "title"))
link = _text(item, "link")
description = _strip_html(_text(item, "description"))
pub_date = _text(item, "pubDate")
if is_google_news and title:
title = _clean_google_news_title(title)
# 描述通常比标题更长,含摘要
if description and len(description) > len(title):
summary = description
else:
summary = title
else:
summary = description or title
if link and title:
items.append({
"title": title,
"url": link.strip(),
"summary": summary,
"publish_time": _parse_date(pub_date),
})
if items:
tag = "Google News RSS" if is_google_news else "RSS 2.0"
logger.debug("%s: 提取 %d", tag, len(items))
return items
# Atom: feed/entry
for entry in root.iter(_ns("entry")):
title = _strip_html(_text(entry, _ns("title")))
link = _attr(entry, _ns("link"), "href")
summary = _strip_html(_text(entry, _ns("summary")))
updated = _text(entry, _ns("updated"))
if link and title:
items.append({
"title": title,
"url": link.strip(),
"summary": summary or title,
"publish_time": _parse_date(updated),
})
logger.debug("Atom: 提取 %d", len(items))
return items
def _text(element: ElementTree.Element, tag: str) -> str:
"""安全获取子元素文本。"""
child = element.find(tag)
return (child.text or "").strip() if child is not None and child.text else ""
def _attr(element: ElementTree.Element, tag: str, attr: str) -> str:
"""安全获取子元素属性。"""
child = element.find(tag)
return (child.get(attr) or "").strip() if child is not None else ""
# ── 主抓取接口 ───────────────────────────────────────
def crawl_rss_source(source: SourceConfig) -> CrawlResult:
"""通过 RSS/Atom Feed 抓取单个新闻源。
支持 Google News RSS 代理模式:
- rss_url 为 news.google.com 时自动识别
- 标题自动去来源后缀
- 不抓取原文(跳过 Crawl4AI),直接用 RSS 摘要入库
Args:
source: 源配置(需含 rss_url 字段)
Returns:
CrawlResult
"""
rss_url = getattr(source, "rss_url", None)
if not rss_url:
return CrawlResult(
source_id=source.id,
source_name=source.name,
error="源未配置 rss_url",
)
today = get_news_day()
out_dir = Path(f"data/raw/{source.id}/{today}")
out_dir.mkdir(parents=True, exist_ok=True)
start_time = datetime.now()
articles: list[ArticleItem] = []
crawl_time = start_time.isoformat()
try:
resp = httpx.get(
rss_url,
follow_redirects=True,
timeout=30.0,
headers={
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36"
),
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
)
resp.raise_for_status()
xml_text = resp.text
except Exception as e:
logger.error("[%s] RSS 抓取失败: %s", source.id, e)
return CrawlResult(
source_id=source.id,
source_name=source.name,
start_time=crawl_time,
end_time=datetime.now().isoformat(),
error=str(e),
)
# 解析 RSS
try:
items = _extract_rss_items(xml_text)
except Exception as e:
logger.error("[%s] RSS 解析失败: %s", source.id, e)
return CrawlResult(
source_id=source.id,
source_name=source.name,
start_time=crawl_time,
end_time=datetime.now().isoformat(),
error=f"RSS 解析失败: {e}",
)
if not items:
logger.warning("[%s] RSS 返回 0 条", source.id)
return CrawlResult(
source_id=source.id,
source_name=source.name,
total_found=0,
start_time=crawl_time,
end_time=datetime.now().isoformat(),
)
# 过滤已存在的 URL(增量)
index_path = out_dir / "index.jsonl"
existing_hashes: set[str] = _load_existing_hashes(index_path)
# 构造 ArticleItem
success = 0
for item in items[:source.max_articles_per_run]:
url_hash = _compute_url_hash(item["url"])
if url_hash in existing_hashes:
logger.debug("[%s] 跳过 RSS 已抓取: %s", source.id, item["url"][:80])
continue
# URL 有效性校验(静态资源 / 跨站污染 / 不匹配 article_url_pattern
if not _is_valid_article_url(item["url"], source):
continue
# 保存文章摘要为 Markdown
md_content = f"# {item['title']}\n\n"
md_content += f"**来源**: {source.name}\n\n"
md_content += f"**发布时间**: {item['publish_time']}\n\n"
md_content += f"**原文链接**: {item['url']}\n\n"
md_content += f"{item['summary']}\n"
md_path = out_dir / f"{url_hash}.md"
md_path.write_text(md_content, encoding="utf-8")
html_path = out_dir / f"{url_hash}.html"
html_content = f"<html><head><title>{item['title']}</title></head><body>{item['summary']}</body></html>"
html_path.write_text(html_content, encoding="utf-8")
article = ArticleItem(
source_id=source.id,
source_name=source.name,
url=item["url"],
url_hash=url_hash,
title=item["title"],
crawl_time=crawl_time,
publish_time=item["publish_time"],
html_path=str(html_path),
md_path=str(md_path),
word_count=len(item["summary"].split()),
status="success",
)
articles.append(article)
existing_hashes.add(url_hash)
success += 1
logger.info("[%s] RSS 抓取: %d 条新文章(总共 %d 条)",
source.id, success, len(items))
return CrawlResult(
source_id=source.id,
source_name=source.name,
total_found=len(items),
total_success=success,
articles=articles,
start_time=crawl_time,
end_time=datetime.now().isoformat(),
)
def _load_existing_hashes(index_path: Path) -> set[str]:
"""读取 index.jsonl 中已有 url_hash。"""
hashes: set[str] = set()
if not index_path.exists():
return hashes
import json
with open(index_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
hashes.add(json.loads(line).get("url_hash", ""))
except json.JSONDecodeError:
continue
return hashes
+95
View File
@@ -0,0 +1,95 @@
"""存储管理:index.jsonl 读写、输出目录管理"""
import json
import logging
from pathlib import Path
from crawler.models import ArticleItem, CrawlResult
from crawler.utils import get_news_day
logger = logging.getLogger(__name__)
def write_index_jsonl(result: CrawlResult) -> Path:
"""将单源抓取结果写入 index.jsonl
Args:
result: 单源抓取结果
Returns:
index 文件路径
"""
today = get_news_day()
out_dir = Path(f"data/raw/{result.source_id}/{today}")
out_dir.mkdir(parents=True, exist_ok=True)
index_path = out_dir / "index.jsonl"
# 追加写入(同一天多次抓取合并)
existing: 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.add(item.get("url_hash", ""))
except json.JSONDecodeError:
continue
written = 0
with open(index_path, "a", encoding="utf-8") as f:
for article in result.articles:
if article.status != "success":
continue
if article.url_hash in existing:
continue # 跳过已存在的
record = article.model_dump()
f.write(json.dumps(record, ensure_ascii=False) + "\n")
existing.add(article.url_hash)
written += 1
logger.info("[%s] index.jsonl 写入 %d 条(跳过重复 %d 条)",
result.source_id, written,
len(result.articles) - written)
return index_path
def load_index(
source_id: str,
date_str: str | None = None,
) -> list[ArticleItem]:
"""读取指定源/日期的 index.jsonl
Args:
source_id: 新闻源 ID
date_str: 日期字符串 YYYYMMDD,默认当前新闻日
Returns:
ArticleItem 列表
"""
if date_str is None:
date_str = get_news_day()
index_path = Path(f"data/raw/{source_id}/{date_str}/index.jsonl")
if not index_path.exists():
logger.warning("index 文件不存在: %s", index_path)
return []
articles: list[ArticleItem] = []
with open(index_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
articles.append(ArticleItem(**data))
except (json.JSONDecodeError, Exception) as e:
logger.warning("解析 index 行失败: %s", e)
return articles
+51
View File
@@ -0,0 +1,51 @@
"""爬虫工具函数"""
import logging
from datetime import datetime, timedelta
from pathlib import Path
import yaml
logger = logging.getLogger(__name__)
# 默认日切分小时(凌晨)
DEFAULT_CUTOFF_HOUR = 6
def _load_cutoff_hour() -> int:
"""从 system.yaml 读取日切分小时"""
config_path = Path("configs/system.yaml")
if config_path.exists():
try:
with open(config_path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
return int(raw.get("schedule", {}).get("day_cutoff_hour", DEFAULT_CUTOFF_HOUR))
except Exception:
pass
return DEFAULT_CUTOFF_HOUR
def get_news_day(now: datetime | None = None) -> str:
"""获取当前新闻日(YYYYMMDD
规则:当天 06:00 到次日 05:59 属于同一个新闻日。
例如 2026-06-19 04:00 → "20260618"07:00 → "20260619"
Args:
now: 参考时间,默认当前时间
Returns:
新闻日字符串 YYYYMMDD
"""
if now is None:
now = datetime.now()
cutoff = _load_cutoff_hour()
if now.hour < cutoff:
# 凌晨 0:00 - 5:59,属于前一天
news_date = now - timedelta(days=1)
else:
news_date = now
return news_date.strftime("%Y%m%d")