diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..29e473f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,21 @@
+# ==========================================
+# English Financial News — 敏感信息
+# 复制为 .env 并填入真实值
+# 仅存放密钥、Token、API 地址
+# ==========================================
+
+# --- DeepSeek ---
+DEEPSEEK_API_KEY=sk-your-deepseek-key
+DEEPSEEK_BASE_URL=https://api.deepseek.com
+
+# --- Qwen / 通义千问 ---
+QWEN_API_KEY=sk-your-qwen-key
+QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+
+# --- DashScope / 阿里百炼 ---
+DASHSCOPE_API_KEY=sk-your-dashscope-key
+DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding
+
+# --- Qdrant ---
+QDRANT_URL=http://localhost:6333
+QDRANT_API_KEY=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dff8073
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,48 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+build/
+.venv/
+venv/
+
+# Environment
+.env
+.env.local
+.env.production
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Project data
+data/raw/
+data/processed/
+data/deduped/
+data/events/
+data/embeddings/
+data/qdrant_storage/
+data/reports/
+
+# Logs
+logs/*.log
+
+# Cache
+.cache/
+*.cache
+
+# pytest
+.pytest_cache/
+htmlcov/
+.coverage
+
+# uv
+uv.lock
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..008a64e
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,716 @@
+# CLAUDE.md
+
+# Claude Code 开发约束(English Financial News 项目)
+
+版本:v1.0
+
+最后更新:2026-06-21
+
+---
+
+# 一、项目定位
+
+本项目目标:
+
+构建面向国际财经新闻的私有化 Deep Research 平台。
+
+核心能力包括:
+
+* 英文财经新闻抓取(Crawl4AI,12 个英文源,部署海外);
+* 英文正文提取(trafilatura);
+* 全文英译中(LLM DeepSeek);
+* 投资事件抽取(LLM);
+* 双语向量知识库(Qdrant);
+* MCP 服务 + Cherry Studio / Claude Code Agent 深度研究;
+* 每日 AI 摘要日报。
+
+本项目不是:
+
+* 自动交易系统;
+* 股票预测系统;
+* 投资顾问系统。
+
+Claude Code 必须始终围绕"研究辅助平台"进行设计。
+
+---
+
+# 二、Claude Code 总体行为准则
+
+Claude Code 必须遵守以下原则:
+
+## 2.1 分阶段开发
+
+禁止一次性完成整个项目。
+
+必须:
+
+* 每次只完成一个 Milestone;
+* 等待人工验收;
+* 验收通过后再进入下一阶段。
+
+禁止:
+
+* 擅自推进后续阶段;
+* 推翻已完成模块。
+
+Milestone 顺序严格按照 english-news-plan.md 第九节执行:
+M0 → M1 → 同步机制 → M2 → M3 → M4 → M5+M6 → M7+日报 → M8
+
+---
+
+## 2.2 最小改动原则
+
+修改代码时:
+
+优先局部修改。
+
+禁止:
+
+为了优化而重写整个模块。
+
+除非明确要求:
+
+"允许重构"。
+
+否则:
+
+保持向后兼容。
+
+---
+
+## 2.3 先理解,再编码
+
+开始编码前必须:
+
+明确:
+
+* 当前目标;
+* 输入;
+* 输出;
+* 验收标准。
+
+如果需求冲突:
+
+必须先提问。
+
+不得自行猜测。
+
+---
+
+## 2.4 部署拓扑意识
+
+本项目采用双服务器架构(详见 english-news-plan.md 第二节):
+
+| 服务器 | SSH | 部署路径 | 职责 |
+|--------|-----|---------|------|
+| 海外 | `ssh ecs-user@8.217.19.253` | `/opt/intlgrab` | M1 抓取 → rsync 推送 |
+| 国内 | `ssh pi@192.168.1.160` | `/home/pi/intlnews` | M2→M8 全链路 + 日报 |
+
+部署优先级:
+
+* 抓取代码优先在海外服务器测试;
+* 其余代码在国内服务器测试;
+* 海外验证通过后,抓取代码同步到国内做全链路联调。
+
+编码时注意:
+
+* 海外侧只负责抓取,无 LLM/Embedding 依赖;
+* 国内侧等待 rsync 同步完成后再启动管道;
+* 哨兵文件(`SYNC_SENTINEL`)是同步完整性的关键信号;
+* `data/raw/` 目录在两台服务器上路径一致。
+
+---
+
+# 三、开发环境规范
+
+## 3.1 Python 版本
+
+统一使用:
+
+Python 3.11
+
+禁止:
+
+* Python 3.13;
+* Python 3.10 以下版本。
+
+---
+
+## 3.2 依赖管理
+
+统一使用:
+
+uv
+
+禁止:
+
+requirements.txt 手工维护。
+
+依赖定义:
+
+pyproject.toml
+
+安装命令:
+
+uv sync
+
+新增依赖:
+
+uv add 包名
+
+开发依赖:
+
+uv add --dev 包名
+
+---
+
+## 3.3 虚拟环境
+
+统一使用:
+
+.venv
+
+禁止:
+
+使用 Conda。
+
+禁止:
+
+多个虚拟环境混用。
+
+---
+
+# 四、代码规范
+
+## 4.1 类型注解
+
+所有新增代码必须包含类型注解。
+
+示例:
+
+def search_news(
+keyword: str,
+top_k: int
+) -> list[dict]:
+...
+
+禁止:
+
+省略类型。
+
+---
+
+## 4.2 中文说明
+
+要求:
+
+代码使用英文命名。
+
+注释与文档使用中文。
+
+例如:
+
+# 新闻去重处理
+
+def deduplicate_articles():
+...
+
+---
+
+## 4.3 函数长度
+
+单个函数:
+
+建议 ≤ 50 行。
+
+超过:
+
+必须拆分。
+
+---
+
+## 4.4 文件长度
+
+单文件:
+
+建议 ≤ 500 行。
+
+超过:
+
+必须拆分模块。
+
+---
+
+## 4.5 禁止魔法数字
+
+禁止:
+
+importance = 5
+
+应写成:
+
+MAX_IMPORTANCE = 5
+
+---
+
+## 4.6 数据模型
+
+统一使用 Pydantic 定义数据结构。
+
+核心模型(定义见 english-news-plan.md 第四节):
+
+* `EnArticle` — 新闻文章(含中英文标题、正文、词数)
+* `EnExtractedEvent` — 投资事件(事件类型、股票代码、情绪、重要度)
+
+禁止:
+
+大量裸 dict 传递。
+
+---
+
+# 五、日志规范
+
+统一使用:
+
+logging
+
+禁止:
+
+print()
+
+日志级别:
+
+DEBUG
+
+INFO
+
+WARNING
+
+ERROR
+
+CRITICAL
+
+日志格式:
+
+时间 - 模块 - 级别
+消息
+
+示例:
+
+2026-06-21 06:30:00 - translator - INFO
+开始翻译 reuters 源文章 15 篇
+
+---
+
+# 六、异常处理规范
+
+禁止:
+
+except:
+pass
+
+必须:
+
+记录日志。
+
+抛出明确异常。
+
+示例:
+
+except Exception as e:
+ logger.exception(e)
+ raise
+
+---
+
+# 七、测试规范
+
+新增功能必须提供测试。
+
+优先使用:
+
+pytest
+
+测试目录:
+
+tests/
+
+命名:
+
+test_xxx.py
+
+测试覆盖:
+
+核心模块必须覆盖。
+
+包括:
+
+* crawler(M1 抓取)
+* extractor(M2 正文提取)
+* dedup(M3 去重)
+* translator(M4a 翻译)
+* llm(M4b 事件抽取)
+* embedding(M5 向量生成)
+* vectorstore(M6 Qdrant 入库/检索)
+* scheduler(M7 定时调度)
+* mcp_server(M8 MCP 服务)
+
+---
+
+# 八、配置规范
+
+## 8.1 禁止硬编码
+
+所有配置项(API Key、URL、路径、阈值、超时、并发数等)一律禁止写在代码中。
+
+常量设置必须按实际情况分配到以下位置:
+
+| 配置类型 | 存放位置 | 示例 |
+|---------|---------|------|
+| 密钥/Token | `.env` | `DEEPSEEK_API_KEY`、`DASHSCOPE_API_KEY` |
+| API 地址 | `.env` | `DEEPSEEK_BASE_URL`、`QWEN_BASE_URL` |
+| Qdrant 连接 | `.env` | `QDRANT_URL`、`QDRANT_API_KEY` |
+| 所有功能配置 | `configs/system.yaml` | 模型名、部署路径、阈值、超时、调度、日志 |
+| 新闻源定义 | `configs/sources.yaml` | 源 ID、URL 模板、JS 渲染开关 |
+
+**原则**:`.env` 仅存 SSH/API 的密钥和地址。其余全部在 `system.yaml`。
+
+代码中只允许引用配置,不允许定义配置值。
+
+正确示例:
+
+```python
+import os
+LLM_API_KEY = os.environ["DEEPSEEK_API_KEY"]
+```
+
+```python
+from yaml import safe_load
+config = safe_load(open("configs/system.yaml"))
+max_articles = config["crawler"]["max_articles_per_run"]
+```
+
+错误示例:
+
+```python
+API_KEY = "sk-xxx" # ❌ 硬编码密钥
+LLM_TIMEOUT = 60 # ❌ 魔法数字硬编码
+SOURCES = [{"id": "reuters", ...}] # ❌ 源配置硬编码
+```
+
+## 8.2 配置文件清单
+
+* `configs/sources.yaml` — 英文财经源定义(id / name / url_pattern / js_render 等)
+* `configs/system.yaml` — 系统级业务参数(去重阈值、LLM 超时、日报限制等)
+* `.env` — 密钥、服务地址(不入 Git)
+* `.env.example` — 脱敏示例(入 Git)
+
+## 8.3 环境变量读取规范
+
+* 始终使用 `os.environ["KEY"]`(失败时抛出明确异常),不使用默认值硬编码;
+* 如需默认值,必须在 `configs/system.yaml` 中定义,代码从配置文件读取;
+* 禁止在 `os.getenv("KEY", "hardcoded_default")` 中写死默认值。
+
+---
+
+# 九、Prompt 管理规范
+
+Prompt 必须独立维护。
+
+目录:
+
+prompts/
+
+禁止:
+
+在代码中直接拼接长 Prompt。
+
+本项目 Prompt 文件:
+
+* `translation_and_extraction.md` — 翻译 + 事件抽取(单次 LLM 调用合并输出)
+* `daily_report.md` — 日报生成 Prompt(五段式结构)
+* `search_agent.md` — MCP Agent 深度研究 Prompt
+
+---
+
+# 十、数据库规范
+
+Qdrant 为唯一向量数据库。
+
+Collection 名称:`en_finance_news`
+
+SQLite 用于:
+
+任务状态;
+缓存;
+调试。
+
+禁止:
+
+引入多种向量数据库。
+
+除非用户明确要求。
+
+---
+
+# 十一、Docker 规范
+
+所有服务必须支持 Docker。
+
+必须提供:
+
+docker-compose.yml
+
+必须支持:
+
+docker compose up -d
+
+启动。
+
+注意:
+
+* 海外侧 docker-compose 仅包含 Crawl4AI + rsync daemon;
+* 国内侧 docker-compose 包含 Qdrant + 调度器 + MCP 服务。
+
+不得依赖:
+
+手工安装。
+
+---
+
+# 十二、Git 提交规范
+
+完成每个 Milestone 后:
+
+更新:
+
+README.md
+
+continuation.md
+
+docs/
+
+提交信息格式:
+
+feat:
+新增功能
+
+fix:
+问题修复
+
+refactor:
+重构
+
+docs:
+文档更新
+
+test:
+测试
+
+chore:
+杂项
+
+示例:
+
+feat: 完成 M1 英文财经新闻抓取模块(Crawl4AI)
+
+---
+
+# 十三、README 更新规范
+
+每次功能完成后:
+
+README 必须更新:
+
+包括:
+
+功能说明;
+
+部署方法(区分海外/国内);
+
+配置说明;
+
+示例命令;
+
+常见问题。
+
+禁止:
+
+README 长期不维护。
+
+---
+
+# 十四、continuation.md 维护规范
+
+Claude Code 每次结束工作前:
+
+必须更新 continuation.md。
+
+内容包括:
+
+当前 Milestone;
+
+完成内容;
+
+待办事项;
+
+已知问题;
+
+技术债务;
+
+下次建议。
+
+用于恢复上下文。
+
+---
+
+# 十五、性能要求
+
+目标:
+
+初始支持 12 个英文财经新闻源(见 english-news-plan.md)。
+
+逐步扩展至 ≥ 50 个源。
+
+处理吞吐:
+
+≥ 100 篇 / 分钟(端到端:抓取 → 翻译 → 入库)。
+
+Qdrant 检索:
+
+Top-K 响应时间 ≤ 2 秒。
+
+LLM 翻译:
+
+单篇平均 ≤ 3 秒(DeepSeek flash 模型)。
+
+---
+
+# 十六、翻译质量规范
+
+LLM 翻译必须:
+
+* 使用 DeepSeek 为默认 Provider(Qwen 为备选);
+* System prompt 约束财经翻译风格:准确、简洁、专业术语一致;
+* 保留英文原标题(`title`)和中文翻译标题(`title_zh`);
+* 保留英文原文(`content_en`)和中文翻译(`content_zh`);
+* 中文译文字数控制在原文 1.0×–1.5× 范围。
+
+事件抽取必须:
+
+* 正确识别涉及的美股代码(如 AAPL、TSLA);
+* 情绪判断有据可查(利好/利空/中性);
+* 重要度 1-5 分,≥ 4 分进入日报"重要事件"板块。
+
+---
+
+# 十七、安全规范
+
+禁止:
+
+提交:
+
+.env
+
+API Key(DeepSeek / DashScope / Qwen)
+
+Cookie
+
+Token
+
+个人隐私数据
+
+SSH 私钥
+
+必须:
+
+提供:
+
+.env.example
+
+示例配置(脱敏)。
+
+---
+
+# 十八、禁止事项
+
+Claude Code 禁止:
+
+1. 未经允许重构已验收模块;
+2. 一次性生成整个项目;
+3. 擅自修改数据库结构(包括 Qdrant Collection Schema);
+4. 删除已有测试;
+5. 使用 print 调试;
+6. 忽略异常;
+7. 跳过验收直接进入下一阶段;
+8. 引入未经说明的新技术栈;
+9. 将 Prompt 写死在代码中;
+10. 编写无法运行的伪代码冒充完成;
+11. 将海外侧依赖(LLM/Embedding)引入 M1 抓取模块;
+12. 擅自新增英文新闻源而不更新 configs/sources.yaml 和 plan。
+
+---
+
+# 十九、输出格式要求
+
+Claude Code 完成任务时必须输出:
+
+【任务目标】
+
+【完成内容】
+
+【修改文件】
+
+【运行方法】
+
+【测试结果】
+
+【存在问题】
+
+【下一步建议】
+
+不得只输出代码。
+
+必须提供可验证说明。
+
+---
+
+# 二十、最高优先级原则
+
+当多个原则冲突时,优先级如下:
+
+第一优先级:
+
+代码正确、可运行。
+
+第二优先级:
+
+稳定性与可维护性。
+
+第三优先级:
+
+向后兼容。
+
+第四优先级:
+
+性能优化。
+
+第五优先级:
+
+代码优雅。
+
+宁可代码普通,也不要复杂炫技。
+
+本项目追求:
+
+"小步迭代、稳定演进、长期维护"。
+
+---
+
+# 二十一、参考文档
+
+* `english-news-plan.md` — 项目总体计划(权威来源)
+* `docs/` — 设计文档
+* 对标项目:`news/`(A 股 Deep Research),架构模式可复用
+
+—— CLAUDE.md 结束 ——
diff --git a/README.md b/README.md
index a5c3634..687f8f2 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,90 @@
-# intl_news
+# English Financial News
+国际财经新闻抓取与深度研究平台。
+
+## 核心能力
+
+- **M1** 英文财经新闻抓取(Crawl4AI,12 个源,部署海外)
+- **M2** 英文正文提取(trafilatura)
+- **M3** 三层去重(URL Hash / 内容 Hash / SimHash 模糊)
+- **M4** 全文英译中 + 投资事件抽取(LLM DeepSeek)
+- **M5** 向量生成(DashScope text-embedding-v3)
+- **M6** 双语向量知识库(Qdrant)
+- **M7** 定时调度 + 每日 AI 摘要日报
+- **M8** MCP 服务(Cherry Studio / Claude Code Agent)
+
+## 部署架构
+
+```
+海外服务器 国内服务器
+M1 抓取 ──rsync──▶ M2→M3→M4→M5→M6
+ ↓
+ 日报 + M7 调度
+ ↓
+ M8 MCP 服务
+```
+
+## 快速开始
+
+### 环境要求
+
+- Python 3.11
+- uv
+
+### 安装
+
+```bash
+# 创建虚拟环境并安装依赖
+uv sync
+
+# 配置环境变量
+cp .env.example .env
+# 编辑 .env 填入真实 API Key
+```
+
+### CLI 命令
+
+```
+uv run en-news --help
+
+# 子命令
+uv run en-news crawl # M1 抓取
+uv run en-news extract # M2 提取
+uv run en-news dedup # M3 去重
+uv run en-news translate # M4 翻译+事件抽取
+uv run en-news embed # M5 向量生成
+uv run en-news index # M6 Qdrant 入库
+uv run en-news search "..." # M6 语义检索
+uv run en-news report # 日报生成
+uv run en-news pipeline # 一键管道
+uv run en-news mcp-server # M8 MCP 服务
+```
+
+## 配置
+
+| 文件 | 用途 |
+|------|------|
+| `configs/sources.yaml` | 英文财经新闻源定义 |
+| `configs/system.yaml` | 系统级业务参数 |
+| `.env` | 密钥 / 服务地址 |
+
+## 开发
+
+```bash
+# 安装开发依赖
+uv sync --dev
+
+# 运行测试
+uv run pytest
+
+# 代码检查
+uv run ruff check .
+```
+
+## 项目计划
+
+详见 [english-news-plan.md](english-news-plan.md)
+
+## 许可证
+
+MIT
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/cli.py b/app/cli.py
new file mode 100644
index 0000000..6d5797b
--- /dev/null
+++ b/app/cli.py
@@ -0,0 +1,255 @@
+"""English Financial News — CLI 入口"""
+
+import logging
+import sys
+
+from dotenv import load_dotenv
+import typer
+
+# 加载 .env 环境变量(必须在所有模块导入之前)
+load_dotenv()
+
+app = typer.Typer(
+ name="en-news",
+ help="国际财经新闻抓取与深度研究平台",
+ no_args_is_help=True,
+)
+
+# ── 日志配置 ──────────────────────────────────────────
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ stream=sys.stderr,
+)
+
+# 抑制第三方库噪音日志
+for _lib in ("httpx", "httpcore", "openai", "urllib3"):
+ logging.getLogger(_lib).setLevel(logging.WARNING)
+
+logger = logging.getLogger("en-news")
+
+
+@app.command()
+def crawl(
+ source: str | None = typer.Option(
+ None, "--source", "-s",
+ help="只抓取指定 source_id(不传则全部)",
+ ),
+ profile: str | None = typer.Option(
+ None, "--profile", "-p",
+ help="Profile 名称(2g_headless / 8g_headful)",
+ ),
+):
+ """M1: 抓取英文财经新闻(部署海外)"""
+ import os
+ from crawler.orchestrator import run_crawl_sync
+
+ if profile:
+ os.environ["EN_NEWS_PROFILE"] = profile
+ logger.info("📋 使用 Profile: %s", profile)
+
+ try:
+ stats = run_crawl_sync(source_filter=source)
+ typer.echo(f"\n✅ 完成: {stats.sources_crawled} 源, {stats.total_articles} 篇文章")
+ if stats.sources_failed:
+ typer.echo(f"⚠️ {stats.sources_failed} 个源有错误")
+ except FileNotFoundError as e:
+ typer.echo(f"❌ {e}", err=True)
+ raise typer.Exit(code=1)
+ except Exception as e:
+ logger.exception("抓取失败")
+ typer.echo(f"❌ 抓取出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def extract(
+ source: str | None = typer.Option(
+ None, "--source", "-s",
+ help="只处理指定 source_id(不传则全部)",
+ ),
+):
+ """M2: 英文正文提取(trafilatura)"""
+ from extractor.pipeline import process_all_sources
+
+ try:
+ stats = process_all_sources(source_filter=source)
+ typer.echo(
+ f"\n✅ 提取: {stats['sources_processed']} 源, "
+ f"{stats['total_articles']} 篇, {stats['elapsed_sec']:.1f}s"
+ )
+ except Exception as e:
+ logger.exception("正文提取失败")
+ typer.echo(f"❌ 提取出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def dedup():
+ """M3: 三层去重"""
+ from dedup.pipeline import dedup_all_sources
+
+ try:
+ stats = dedup_all_sources()
+ typer.echo(
+ f"\n✅ 去重: {stats['sources_processed']} 源, "
+ f"唯一 {stats['unique']} / 重复 {stats['duplicate']} / "
+ f"总计 {stats['total_articles']}, {stats['elapsed_sec']:.1f}s"
+ )
+ except Exception as e:
+ logger.exception("去重失败")
+ typer.echo(f"❌ 去重出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def translate():
+ """M4: 全文翻译 + 投资事件抽取(LLM)"""
+ from llm.pipeline import translate_all_deduped
+
+ try:
+ stats = translate_all_deduped()
+ typer.echo(
+ f"\n✅ 翻译+事件抽取: {stats['success']}/{stats['total']} 篇, "
+ f"{stats['elapsed_sec']:.1f}s ({stats['provider']}/{stats['model']})"
+ )
+ except Exception as e:
+ logger.exception("翻译失败")
+ typer.echo(f"❌ 翻译出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def embed():
+ """M5: 向量生成"""
+ from embedding.pipeline import embed_all_events
+
+ try:
+ stats = embed_all_events()
+ typer.echo(
+ f"\n✅ 向量生成: {stats['success']}/{stats['total']} 篇, "
+ f"{stats['elapsed_sec']:.1f}s ({stats['provider']}/{stats['model']})"
+ )
+ except Exception as e:
+ logger.exception("向量生成失败")
+ typer.echo(f"❌ 向量生成出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def index(
+ recreate: bool = typer.Option(
+ False, "--recreate",
+ help="重建 collection(会删除已有数据)",
+ ),
+):
+ """M6: Qdrant 入库"""
+ from vectorstore.pipeline import get_collection_info, ingest_all_embeddings
+
+ try:
+ stats = ingest_all_embeddings(recreate=recreate)
+ typer.echo(
+ f"\n✅ 入库: {stats['ingested']}/{stats['total']} 条, "
+ f"{stats['elapsed_sec']:.1f}s"
+ )
+ info = get_collection_info()
+ typer.echo(f"📊 Collection: {info['name']} — {info['vectors_count']} 条向量")
+ except Exception as e:
+ logger.exception("入库失败")
+ typer.echo(f"❌ 入库出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def search(
+ query: str = typer.Argument(..., help="中文检索查询"),
+ top_k: int = typer.Option(10, help="返回结果数"),
+):
+ """M6: Qdrant 语义检索"""
+ from vectorstore.pipeline import search_news
+
+ try:
+ results = search_news(query, top_k=top_k)
+ if not results:
+ typer.echo("🔍 未找到相关结果")
+ return
+
+ typer.echo(f"\n🔍 搜索: {query}\n")
+ for i, r in enumerate(results, 1):
+ typer.echo(f"{i}. [{r.source_id}] score={r.score:.4f}")
+ typer.echo(f" 📰 {r.title_zh or r.title}")
+ if r.events:
+ events_summary = ", ".join(
+ f"{ev.get('event_type','')}({ev.get('sentiment','')})"
+ for ev in r.events[:3]
+ )
+ typer.echo(f" 📌 事件: {events_summary}")
+ typer.echo(f" 🔗 {r.url}")
+ typer.echo()
+ except Exception as e:
+ logger.exception("搜索失败")
+ typer.echo(f"❌ 搜索出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def report():
+ """M7: 日报生成"""
+ from scheduler.reporter import generate_report
+
+ try:
+ path = generate_report()
+ if path:
+ typer.echo(f"\n✅ 日报已生成: {path}")
+ else:
+ typer.echo("⚠️ 无数据,跳过日报生成")
+ except Exception as e:
+ logger.exception("日报生成失败")
+ typer.echo(f"❌ 日报生成出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def pipeline(
+ skip_report: bool = typer.Option(
+ False, "--skip-report",
+ help="跳过日报生成",
+ ),
+):
+ """M7: 一键运行完整管道 M2→M6(+ 可选日报)"""
+ from crawler.utils import get_news_day
+ from scheduler.pipeline import run_pipeline
+
+ date_str = get_news_day()
+ typer.echo(f"🚀 开始全链路管道,日期: {date_str}\n")
+
+ try:
+ result = run_pipeline(date_str, skip_report=skip_report)
+
+ typer.echo(f"\n{'='*50}")
+ typer.echo(f"Pipeline 完成: {result.success_count}/{len(result.steps)} 成功")
+ for s in result.steps:
+ flag = "✅" if s.success else "❌"
+ typer.echo(f" {flag} {s.name}: {s.message} ({s.elapsed_sec:.0f}s)")
+ if result.finished_at and result.started_at:
+ total = (result.finished_at - result.started_at).total_seconds()
+ typer.echo(f"\n⏱ 总耗时: {total:.0f}s")
+ except Exception as e:
+ logger.exception("管道执行失败")
+ typer.echo(f"❌ 管道执行出错: {e}", err=True)
+ raise typer.Exit(code=1)
+
+
+@app.command()
+def mcp_server():
+ """M8: 启动 MCP 服务(供 Claude Code / Cherry Studio 调用)"""
+ from mcp_server.server import mcp
+
+ typer.echo("🚀 启动国际财经 Deep Research MCP 服务...")
+ mcp.run()
+
+
+if __name__ == "__main__":
+ app()
diff --git a/configs/profiles/2g_headless.yaml b/configs/profiles/2g_headless.yaml
new file mode 100644
index 0000000..4a1ac32
--- /dev/null
+++ b/configs/profiles/2g_headless.yaml
@@ -0,0 +1,25 @@
+# ════════════════════════════════════════════════════
+# Profile: 2G 轻量 headless stealth
+# ════════════════════════════════════════════════════
+# 适用场景:
+# - 海外服务器(3.5GB 总内存,约 1.2GB 可用)
+# - 国内服务器低负载补充抓取
+# - 10 个 RSS 优先源 + 2 个 Web 回退源
+# 策略:
+# - stealth headless(隐藏 webdriver 特征)
+# - 串行逐源抓取,内存超限自动 GC
+# - 单源 2 小时超时,单页 45 秒超时
+# 部署:bash scripts/domestic_crawl_2g.sh [source_id]
+# ════════════════════════════════════════════════════
+
+proxy:
+ enabled: false # 海外服务器无需代理;国内如需,改为 true
+
+crawler:
+ max_memory_mb: 1800 # 内存上限
+ source_timeout_sec: 7200 # 单源超时 2h
+ article_delay_sec: 3.0 # 文章间冷却
+ page_timeout_sec: 45 # 单页超时
+ viewport_width: 1024
+ viewport_height: 768
+ headful: false # headless stealth
diff --git a/configs/profiles/8g_headful.yaml b/configs/profiles/8g_headful.yaml
new file mode 100644
index 0000000..adf5827
--- /dev/null
+++ b/configs/profiles/8g_headful.yaml
@@ -0,0 +1,32 @@
+# ════════════════════════════════════════════════════
+# Profile: 8G headful + HTTP 代理 → Privoxy → SOCKS5
+# ════════════════════════════════════════════════════
+# 适用场景:
+# - 国内服务器(7.9GB 总内存,约 6.1GB 可用)
+# - 所有源均使用 headful Playwright 浏览器直接抓取
+# - 通过 Privoxy (HTTP:3128) → ss-local (SOCKS5:1088) 代理访问海外网站
+# 策略:
+# - headful 模式 + Xvfb 虚拟显示器(更像真人)
+# - HTTP 代理 127.0.0.1:3128
+# - 更高视口、更长超时
+# - 串行逐源(内存充裕但仍需控制并发)
+# - 单源 3 小时超时,单页 60 秒超时
+# 前置条件:
+# sudo apt install xvfb
+# Xvfb :99 -screen 0 1280x1024x24 &
+# 部署:bash scripts/domestic_crawl_8g.sh [source_id]
+# ════════════════════════════════════════════════════
+
+proxy:
+ enabled: true # 国内服务器通过 HTTP 代理访问海外网站
+ url: "http://127.0.0.1:3128"
+
+crawler:
+ max_memory_mb: 7500 # 内存充裕
+ source_timeout_sec: 10800 # 单源超时 3h(反爬源可能更慢)
+ article_delay_sec: 4.0 # 文章间冷却更长(更像真人)
+ page_timeout_sec: 60 # 单页超时 60s
+ viewport_width: 1280 # 更正常的视口
+ viewport_height: 900
+ headful: true # 有头浏览器 + Xvfb
+ xvfb_display: ":99" # Xvfb 虚拟显示器
diff --git a/configs/sources.yaml b/configs/sources.yaml
new file mode 100644
index 0000000..4df871a
--- /dev/null
+++ b/configs/sources.yaml
@@ -0,0 +1,141 @@
+# 英文财经新闻源配置
+# 每个源的字段说明见 english-news-plan.md M1 章节
+
+settings:
+ concurrency: 5
+ request_delay_sec: 2
+ user_agent: "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"
+
+sources:
+ - id: "reuters"
+ name: "Reuters"
+ enabled: true
+ homepage: "https://www.reuters.com/business/"
+ article_url_pattern: "/[^/]+/[^/]+/"
+ js_render: false
+ max_articles_per_run: 30
+ anti_bot_mode: "stealth" # DataDome 反爬(Web 回退,大概率失败)
+ rss_url: "https://news.google.com/rss/search?q=site:reuters.com+business&hl=en-US&gl=US&ceid=US:en" # Google News RSS 代理(标题+摘要,绕过 DataDome)
+ # ⚠️ Google News RSS 链接为 Google 跳转 URL,无法获取 reuters.com 原文 URL
+ # rss_crawler.py 自动识别 Google News RSS 格式,标题去 " - Reuters" 后缀
+
+ - id: "cnbc"
+ name: "CNBC"
+ enabled: true
+ homepage: "https://www.cnbc.com/world/"
+ article_url_pattern: "/\\d{4}/\\d{2}/\\d{2}/"
+ js_render: true
+ max_articles_per_run: 25
+ rss_url: "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114" # US Top News and Analysis
+ # 其他可用频道(id 参数切换):
+ # id=15837362 → U.S. News
+ # id=15839135 → Earnings
+ # id=10000116 → Retail
+
+ - id: "marketwatch"
+ name: "MarketWatch"
+ enabled: true
+ homepage: "https://www.marketwatch.com/latest-news"
+ article_url_pattern: "/story/"
+ js_render: false
+ max_articles_per_run: 25
+ rss_url: "https://feeds.marketwatch.com/marketwatch/topstories" # RSS 优先,绕过 DataDome
+ anti_bot_mode: "stealth" # RSS 失败回退(headful 在无头服务器不可用)
+ # 其他可用频道:
+ # https://feeds.marketwatch.com/marketwatch/marketpulse → MarketPulse
+ # https://feeds.marketwatch.com/marketwatch/bulletins → Bulletins
+
+ - id: "ft"
+ name: "Financial Times"
+ enabled: true
+ homepage: "https://www.ft.com/world"
+ article_url_pattern: "/content/"
+ js_render: false
+ max_articles_per_run: 20
+ rss_url: "https://www.ft.com/rss/home" # FT International Homepage RSS
+ # 其他可用频道:
+ # https://www.ft.com/rss/world
+ # https://www.ft.com/rss/companies
+ # https://www.ft.com/rss/markets
+ # ⚠️ RSS 含摘要+缩略图,但全文仍需 Crawl4AI 抓取(FT 付费墙)
+
+ - id: "yahoo_finance"
+ name: "Yahoo Finance"
+ enabled: true
+ homepage: "https://finance.yahoo.com/news/"
+ article_url_pattern: "/(news|articles)/" # /news/、/economy/.../articles/、/markets/.../articles/
+ js_render: true
+ max_articles_per_run: 30
+ rss_url: "https://news.yahoo.com/rss/finance" # Yahoo Finance RSS
+
+ - id: "investing"
+ name: "Investing.com"
+ enabled: true
+ homepage: "https://www.investing.com/news/latest-news"
+ article_url_pattern: "/news/"
+ js_render: true
+ max_articles_per_run: 25
+ rss_url: "https://www.investing.com/rss/news_1063.rss" # RSS 绕过反爬
+
+ - id: "seekingalpha"
+ name: "Seeking Alpha"
+ enabled: true
+ homepage: "https://seekingalpha.com/market-news"
+ article_url_pattern: "/news/"
+ js_render: true
+ max_articles_per_run: 20
+ anti_bot_mode: "stealth" # 首页 OK,文章页 PerimeterX 拦截 → stealth
+ rss_url: "https://seekingalpha.com/feed.xml" # 全站文章 RSS(标题+摘要+链接)
+
+ - id: "barrons"
+ name: "Barron's"
+ enabled: true
+ homepage: "https://www.barrons.com/latest-news"
+ article_url_pattern: "/articles/"
+ js_render: false
+ max_articles_per_run: 20
+ anti_bot_mode: "stealth" # HTTP 403 → stealth 回退
+ # ⚠️ 无 Barron's 专属原生 RSS(2026-06-21 实测:feeds.content.dowjones.io/rss/barrons→404,feeds.a.dj.com/rss/RSSBarrons.xml→403)
+ # 替代:使用 Dow Jones RSS World News 作为兜底
+ rss_url: "https://feeds.a.dj.com/rss/RSSWorldNews.xml" # Dow Jones World News(非 Barron's 专属)
+
+ - id: "wsj"
+ name: "Wall Street Journal"
+ enabled: true
+ homepage: "https://www.wsj.com/economy"
+ article_url_pattern: "/articles/"
+ js_render: false
+ max_articles_per_run: 20
+ anti_bot_mode: "stealth" # DataDome → RSS 优先 + stealth 回退
+ rss_url: "https://feeds.a.dj.com/rss/RSSWorldNews.xml" # WSJ World News
+ # 其他可用频道:
+ # https://feeds.a.dj.com/rss/RSSMarketsMain.xml → Markets
+ # https://feeds.a.dj.com/rss/RSSWSJD.xml → WSJ Digital (付费墙内)
+
+ - id: "economist"
+ name: "The Economist"
+ enabled: true # ✅ RSS 可用(2026-06-21 验证),RSS 优先;Crawl4AI 回退
+ homepage: "https://www.economist.com/finance-and-economics"
+ article_url_pattern: "/finance-and-economics/\\d{4}/\\d{2}/\\d{2}/"
+ js_render: true
+ max_articles_per_run: 15
+ anti_bot_mode: "stealth"
+ rss_url: "https://www.economist.com/finance-and-economics/rss.xml" # Finance & economics RSS(含标题+摘要+链接)
+
+ - id: "forexlive"
+ name: "ForexLive"
+ enabled: true
+ homepage: "https://www.forexlive.com/"
+ article_url_pattern: "/(news|technical-analysis|Education)/"
+ js_render: false
+ max_articles_per_run: 20
+ rss_url: "https://www.forexlive.com/feed" # Breaking News RSS(含正文,~91KB/次)
+
+ - id: "zerohedge"
+ name: "ZeroHedge"
+ enabled: true
+ homepage: "https://www.zerohedge.com/"
+ article_url_pattern: "/(geopolitical|markets|economics|commodities|technology|political|macroeconomics|crypto|energy|health|esg)/"
+ js_render: true
+ max_articles_per_run: 15
+ rss_url: "http://feeds.feedburner.com/zerohedge/feed" # FeedBurner RSS(含正文,~262KB/次)
diff --git a/configs/system.yaml b/configs/system.yaml
new file mode 100644
index 0000000..6964e02
--- /dev/null
+++ b/configs/system.yaml
@@ -0,0 +1,91 @@
+# ════════════════════════════════════════════════════
+# 系统功能配置(非敏感)
+# 密钥、API 地址 请在 .env 中配置
+# ════════════════════════════════════════════════════
+
+# ── 部署拓扑 ────────────────────────────────────────
+# 海外服务器已弃用(M1-M6 全链路在 domestic 执行)
+servers:
+ overseas_host: "ecs-user@8.217.19.253"
+ overseas_path: "/opt/intlgrab"
+ domestic_host: "pi@192.168.1.160"
+ domestic_path: "/home/pi/intlnews"
+
+# ── HTTP 代理(Privoxy → ss-local → Shadowsocks 海外访问)──
+proxy:
+ enabled: false # 通过 Profile 8g_headful 启用
+ url: "http://127.0.0.1:3128" # HTTP 代理地址
+ bypass_domains: [] # 不走代理的域名(如国内 CDN)
+
+# ── 抓取 ────────────────────────────────────────────
+crawler:
+ max_memory_mb: 1800 # 内存上限 MB(超限触发 GC)
+ source_timeout_sec: 7200 # 单源超时秒数(2h)
+ article_delay_sec: 3.0 # 文章间冷却间隔
+ page_timeout_sec: 45 # 单页加载超时
+ viewport_width: 1024
+ viewport_height: 768
+ 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: false # true=有头浏览器(需 X Server 或 xvfb),false=headless
+ xvfb_display: ":99" # headful 模式下的虚拟显示器(xvfb-run 自动设置)
+
+# ── 正文提取 ──────────────────────────────────────────
+extractor:
+ min_content_words: 50
+ trafilatura_fallback: true
+
+# ── 去重 ────────────────────────────────────────────
+dedup:
+ hamming_distance_threshold: 3
+ simhash_window_days: 30
+ min_content_length: 100
+
+# ── LLM 翻译+事件抽取 ────────────────────────────────
+llm:
+ provider: "deepseek"
+ deepseek_model: "deepseek-chat"
+ qwen_model: "qwen-plus"
+ timeout_sec: 60
+ max_retries: 3
+ max_tokens: 8192
+ temperature: 0.1
+ concurrency: 3
+
+# ── Embedding 向量化 ────────────────────────────────
+embedding:
+ provider: "dashscope"
+ dashscope_model: "text-embedding-v3"
+ dimension: 1024
+ batch_size: 10
+ timeout_sec: 30
+
+# ── Qdrant ──────────────────────────────────────────
+qdrant:
+ collection: "en_finance_news"
+
+# ── 日报 ────────────────────────────────────────────
+report:
+ max_events: 20
+ summary_max_chars: 500
+ importance_threshold: 4
+ upload_host: "simon@doorcome.cn"
+ upload_path: "/var/www/html/echart/research"
+
+# ── 同步 (rsync) ─────────────────────────────────────
+# 海外服务器已弃用,以下保留作为参考
+sync:
+ port: 22
+ sentinel: "/tmp/en_news_sync_done"
+ pack_dir: "/tmp"
+
+# ── 定时调度 ────────────────────────────────────────
+# 全流程:M1 抓取 → M2→M6 管道 → 日报
+# 每天 06:00 / 12:00 / 18:00 / 22:00 各执行一次
+schedule:
+ day_cutoff_hour: 6
+ times: ["06:00", "12:00", "18:00", "22:00"]
+
+# ── 日志 ────────────────────────────────────────────
+logging:
+ level: "INFO"
+ dir: "logs"
diff --git a/continuation.md b/continuation.md
new file mode 100644
index 0000000..2f5a251
--- /dev/null
+++ b/continuation.md
@@ -0,0 +1,146 @@
+# continuation.md — English Financial News 项目状态
+
+> 最后更新:2026-07-17
+
+---
+
+## 2026-07-17 修复
+
+### domestic_crawl_8g.sh 无参崩溃
+- `"$1"` 触发 `set -u` → `"${1:-}"`
+- 已同步 Pi,语法检查通过
+
+---
+
+## 当前进度
+
+```
+M0 ✅ 项目骨架
+M1 ✅ 新闻抓取(12/12 源,Pi headful Playwright + HTTP 代理)
+M2 ✅ 正文提取(trafilatura + MD 回退,增量跳过已处理)
+M3 ✅ 三层去重(L1 URL / L2 Content / L3 SimHash,SQLite 指纹库)
+M4 ✅ 翻译+事件抽取(DeepSeek v4-flash,事件去重 Prompt 约束)
+M5 ✅ 向量生成(DashScope text-embedding-v3,1024 维)
+M6 ✅ Qdrant 入库(本地文件模式,语义搜索验证)
+M7 ✅ 全链路管道 + 日报(事件级去重 + AI 摘要分批合并 + 防截断)
+M8 ✅ MCP 服务(FastMCP 5 个 Tool)
+```
+
+---
+
+## 架构变更(2026-07-14)
+
+### 之前
+
+```
+海外 M1 (crawl) → pack → rsync → Pi M2→M6 → 日报
+```
+
+### 现在
+
+```
+Pi M1 (headful+proxy) → Pi M2→M6 → 日报
+(海外服务器不再参与流水线)
+```
+
+### 修改文件
+
+| 文件 | 改动 |
+|------|------|
+| `configs/profiles/8g_headful.yaml` | proxy URL: `socks5://1080` → `http://127.0.0.1:3128` |
+| `configs/system.yaml` | 调度时间 `06:00/12:00/18:00/22:00`,同步段标为参考 |
+| `scripts/domestic_full.sh` | 重写:移除海外 SSH 触发/sync,2 步:M1 抓取 → 管道+日报 |
+| `scripts/domestic_crawl_8g.sh` | 注释更新 |
+| `scheduler/jobs.py` | 移除海外 crontab,国内统一 domestic_full.sh 每日 4 次 |
+
+### 抓取能力测试结果
+
+| 源 | 结果 | 说明 |
+|----|------|------|
+| Forexlive | ✅ 13/13 Web 100% | headful 绕过 RSS 限制,获取正文 |
+| MarketWatch | ✅ 3 篇 Web 新增 | DataDome 部分破解 |
+| SeekingAlpha | ✅ RSS 稳定 | 30 篇增量跳过,无需 Web |
+| WSJ | ✅ RSS 稳定 | Dow Jones RSS 兜底 |
+| Barron's | ✅ RSS 稳定 | Dow Jones RSS 兜底 |
+| CNBC/Yahoo/ZeroHedge/Economist | ✅ RSS 稳定 | 增量跳过 |
+| **Reuters** | ❌ DataDome captcha | headful+proxy+stealth 均无法绕过 |
+| **FT** | ❌ 超时 | RSS 超时,首页无可用链接 |
+| **Investing.com** | ❌ Cloudflare 拦截 | RSS 403,Web 被 Cloudflare JS 挑战拦截 |
+
+---
+
+## 2026-07-14 会话成果
+
+### Pi Shadowsocks 客户端部署
+
+- `shadowsocks-libev` + `v2ray-plugin v1.3.2`(arm64)已安装
+- `ss-local` 配置 `0.0.0.0:1088`(SOCKS5),systemd 已启用
+- `privoxy` 配置 `0.0.0.0:3128`(HTTP → SOCKS5),已运行
+- 配置文件 `/etc/shadowsocks-libev/config.json` 已创建(服务端信息待用户填入)
+
+### 全链路独立化
+
+- 海外服务器不再承担 M1 抓取任务
+- Pi 通过 `8g_headful` profile(`EN_NEWS_PROFILE`)执行 headful Playwright + HTTP 代理抓取
+- `domestic_full.sh` 简化为 2 步:M1 抓取 → 管道(含日报)
+
+---
+
+## 服务器状态
+
+| 角色 | 地址 | 路径 | 状态 |
+|------|------|------|------|
+| 海外 | `ecs-user@8.217.19.253` | `/opt/intlgrab` | ⏸️ 不再参与流水线,crontab 待停用 |
+| 国内 | `pi@192.168.1.160` | `/home/pi/intlnews` | ✅ 全链路独立运行 |
+
+---
+
+## 定时任务(待设置)
+
+### Pi crontab(需用户手动执行 `crontab -e`)
+
+```
+# 全流程:M1 抓取 → M2→M6 管道 → 日报
+0 6 * * * cd /home/pi/intlnews && bash scripts/domestic_full.sh >> logs/full.log 2>&1
+0 12 * * * cd /home/pi/intlnews && bash scripts/domestic_full.sh >> logs/full.log 2>&1
+0 18 * * * cd /home/pi/intlnews && bash scripts/domestic_full.sh >> logs/full.log 2>&1
+0 22 * * * cd /home/pi/intlnews && bash scripts/domestic_full.sh >> logs/full.log 2>&1
+
+# Xvfb 开机自启
+@reboot Xvfb :99 -screen 0 1280x1024x24 -ac +extension RANDR &
+```
+
+### 海外服务器 crontab(待停用)
+
+```
+# 当前仍在运行,需停用
+# 0 6 * * * overseas_crawl.sh
+# 0 11 * * * overseas_crawl.sh
+# 0 17 * * * overseas_crawl.sh
+# 0 23 * * * overseas_crawl.sh
+```
+
+---
+
+## 待办事项
+
+1. **用户填入 Shadowsocks 服务端信息** → 启动 ss-local
+2. **Pi crontab 设置** → 全流程定时 + Xvfb 开机自启
+3. **海外 crontab 停用** → `crontab -e` 注释掉抓取调度
+4. **Reuters / FT / Investing.com** → 反爬问题未解决,需后续策略
+5. **日报新闻源单一问题** → 前次分析已定位根因(域名映射、RSS 摘要质量),未修复
+
+---
+
+## CLI 命令速查
+
+```
+uv run en-news crawl M1 全量(默认 profile)
+uv run en-news crawl --profile 8g_headful M1 headful + proxy
+uv run en-news pipeline M2→M6 全链路
+uv run en-news report M7 日报
+uv run en-news search "query" M6 搜索
+uv run en-news mcp-server M8 MCP
+bash scripts/domestic_full.sh 全流程(M1→M6→日报)
+bash scripts/domestic_crawl_8g.sh 仅 M1 headful
+```
diff --git a/crawler/__init__.py b/crawler/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/crawler/config.py b/crawler/config.py
new file mode 100644
index 0000000..cf64074
--- /dev/null
+++ b/crawler/config.py
@@ -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
diff --git a/crawler/crawler.py b/crawler/crawler.py
new file mode 100644
index 0000000..68f40a3
--- /dev/null
+++ b/crawler/crawler.py
@@ -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
diff --git a/crawler/loader.py b/crawler/loader.py
new file mode 100644
index 0000000..cc1e0d9
--- /dev/null
+++ b/crawler/loader.py
@@ -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
diff --git a/crawler/models.py b/crawler/models.py
new file mode 100644
index 0000000..dd20cf0
--- /dev/null
+++ b/crawler/models.py
@@ -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 8601,RSS 源可提取,网页源由 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)
diff --git a/crawler/orchestrator.py b/crawler/orchestrator.py
new file mode 100644
index 0000000..3be568d
--- /dev/null
+++ b/crawler/orchestrator.py
@@ -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 == 0,RSS 返回空
+ 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))
diff --git a/crawler/rss_crawler.py b/crawler/rss_crawler.py
new file mode 100644
index 0000000..5c19ffb
--- /dev/null
+++ b/crawler/rss_crawler.py
@@ -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"
{item['title']}{item['summary']}"
+ 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
diff --git a/crawler/storage.py b/crawler/storage.py
new file mode 100644
index 0000000..9304dc9
--- /dev/null
+++ b/crawler/storage.py
@@ -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
diff --git a/crawler/utils.py b/crawler/utils.py
new file mode 100644
index 0000000..3a9d172
--- /dev/null
+++ b/crawler/utils.py
@@ -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")
diff --git a/dedup/__init__.py b/dedup/__init__.py
new file mode 100644
index 0000000..e175536
--- /dev/null
+++ b/dedup/__init__.py
@@ -0,0 +1,53 @@
+"""三层新闻去重模块 (M3)。
+
+公共 API:
+ - Deduper: 主类(check / ingest / stats)
+ - FingerprintStore: SQLite 指纹库(底层,通常无需直接用)
+ - DedupResult / DedupLayer / DedupStats / Fingerprint: 数据模型
+ - simhash64 / hamming / content_hash / normalize_content: 指纹算法
+ - dedup_all_sources / dedup_source: 批量去重管道
+"""
+
+from dedup.deduper import (
+ DEFAULT_TIME_WINDOW_DAYS,
+ Deduper,
+ article_to_fingerprint,
+)
+from dedup.hasher import (
+ DEFAULT_HAMMING_THRESHOLD,
+ NGRAM_SIZE,
+ SIMHASH_BITS,
+ content_hash,
+ hamming,
+ normalize_content,
+ simhash64,
+)
+from dedup.models import DedupLayer, DedupResult, DedupStats, Fingerprint
+from dedup.pipeline import dedup_all_sources, dedup_source
+from dedup.store import DEFAULT_DB_PATH, FingerprintStore
+
+__all__ = [
+ # 主类
+ "Deduper",
+ "FingerprintStore",
+ # 管道
+ "dedup_all_sources",
+ "dedup_source",
+ # 模型
+ "DedupLayer",
+ "DedupResult",
+ "DedupStats",
+ "Fingerprint",
+ # 指纹算法
+ "article_to_fingerprint",
+ "content_hash",
+ "hamming",
+ "normalize_content",
+ "simhash64",
+ # 常量
+ "DEFAULT_DB_PATH",
+ "DEFAULT_HAMMING_THRESHOLD",
+ "DEFAULT_TIME_WINDOW_DAYS",
+ "NGRAM_SIZE",
+ "SIMHASH_BITS",
+]
diff --git a/dedup/deduper.py b/dedup/deduper.py
new file mode 100644
index 0000000..82d7a02
--- /dev/null
+++ b/dedup/deduper.py
@@ -0,0 +1,178 @@
+"""三层去重主流程。
+
+调用顺序: check / ingest 内部按 L1 → L2 → L3 顺序判定,任意层命中即返回。
+
+Deduper 不要求线程安全;批处理串行调用即可。
+"""
+
+import logging
+from datetime import datetime
+from pathlib import Path
+
+import yaml
+
+from dedup.hasher import DEFAULT_HAMMING_THRESHOLD, content_hash, hamming, simhash64
+from dedup.models import DedupLayer, DedupResult, DedupStats, Fingerprint
+from dedup.store import DEFAULT_DB_PATH, FingerprintStore
+from extractor.models import ProcessedArticle
+
+logger = logging.getLogger(__name__)
+
+# 默认时间窗口(±N 天)
+DEFAULT_TIME_WINDOW_DAYS = 30
+
+
+def _load_dedup_config() -> dict:
+ """从 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 raw.get("dedup", {})
+ except Exception:
+ logger.warning("加载 dedup 配置失败,使用默认值")
+ return {}
+
+
+def _publish_date(article: ProcessedArticle) -> str | None:
+ """从 ProcessedArticle.publish_time 取 YYYY-MM-DD 字符串。"""
+ if not article.publish_time:
+ return None
+ # publish_time 格式为 ISO 8601,如 "2026-06-21T10:30:00"
+ try:
+ return article.publish_time[:10]
+ except (IndexError, TypeError):
+ return None
+
+
+def article_to_fingerprint(article: ProcessedArticle) -> Fingerprint:
+ """构造 Fingerprint(用于 ingest 写入或对外只读)。"""
+ return Fingerprint(
+ url_hash=article.url_hash,
+ content_hash=content_hash(article.content),
+ simhash=simhash64(article.content),
+ source_id=article.source_id,
+ url=article.url,
+ title=article.title,
+ publish_date=_publish_date(article),
+ ingested_at=datetime.now(),
+ )
+
+
+class Deduper:
+ """三层去重器。
+
+ 构造完毕后:
+ - check(article) 仅判断,不写入
+ - ingest(article) 判断,不重复则写入指纹库,返回结果
+ """
+
+ def __init__(
+ self,
+ db_path: str | Path = DEFAULT_DB_PATH,
+ simhash_threshold: int | None = None,
+ time_window_days: int | None = None,
+ ) -> None:
+ config = _load_dedup_config()
+
+ self.store = FingerprintStore(db_path)
+ self.simhash_threshold = (
+ simhash_threshold
+ if simhash_threshold is not None
+ else config.get("hamming_distance_threshold", DEFAULT_HAMMING_THRESHOLD)
+ )
+ self.time_window_days = (
+ time_window_days
+ if time_window_days is not None
+ else config.get("simhash_window_days", DEFAULT_TIME_WINDOW_DAYS)
+ )
+
+ def close(self) -> None:
+ self.store.close()
+
+ def __enter__(self) -> "Deduper":
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ self.close()
+
+ # ------------------------------------------------------------------ #
+ # 公共 API
+ # ------------------------------------------------------------------ #
+
+ def check(self, article: ProcessedArticle) -> DedupResult:
+ """三层判重(只读,不写入指纹库)。"""
+ fp = article_to_fingerprint(article)
+
+ # L1: URL hash
+ existing = self.store.get_by_url_hash(fp.url_hash)
+ if existing is not None:
+ return DedupResult(
+ url_hash=fp.url_hash,
+ is_duplicate=True,
+ matched_layer=DedupLayer.URL,
+ matched_url_hash=existing.url_hash,
+ matched_url=existing.url,
+ matched_title=existing.title,
+ )
+
+ # L2: 内容 hash
+ existing = self.store.find_by_content_hash(fp.content_hash)
+ if existing is not None:
+ return DedupResult(
+ url_hash=fp.url_hash,
+ is_duplicate=True,
+ matched_layer=DedupLayer.CONTENT,
+ matched_url_hash=existing.url_hash,
+ matched_url=existing.url,
+ matched_title=existing.title,
+ )
+
+ # L3: SimHash 模糊
+ candidates = self.store.candidates_for_simhash(
+ fp.publish_date, self.time_window_days
+ )
+ best_dist: int | None = None
+ best_match: Fingerprint | None = None
+ for c in candidates:
+ d = hamming(fp.simhash, c.simhash)
+ if d <= self.simhash_threshold and (best_dist is None or d < best_dist):
+ best_dist = d
+ best_match = c
+ if d == 0: # 不可能更近,提前结束
+ break
+
+ if best_match is not None:
+ return DedupResult(
+ url_hash=fp.url_hash,
+ is_duplicate=True,
+ matched_layer=DedupLayer.SIMHASH,
+ matched_url_hash=best_match.url_hash,
+ matched_url=best_match.url,
+ matched_title=best_match.title,
+ hamming_distance=best_dist,
+ )
+
+ return DedupResult(url_hash=fp.url_hash, is_duplicate=False)
+
+ def ingest(self, article: ProcessedArticle) -> DedupResult:
+ """判重 + 不重复则入库。"""
+ result = self.check(article)
+ if not result.is_duplicate:
+ fp = article_to_fingerprint(article)
+ self.store.upsert(fp)
+ logger.debug("指纹入库: %s %s", fp.url_hash, fp.title[:40])
+ else:
+ logger.debug("命中重复: %s", result.short_summary())
+ return result
+
+ def stats(self) -> DedupStats:
+ """指纹库统计信息。"""
+ lo, hi = self.store.date_range()
+ return DedupStats(
+ total=self.store.count(),
+ by_source=self.store.count_by_source(),
+ earliest=lo,
+ latest=hi,
+ )
diff --git a/dedup/hasher.py b/dedup/hasher.py
new file mode 100644
index 0000000..7f98889
--- /dev/null
+++ b/dedup/hasher.py
@@ -0,0 +1,89 @@
+"""三层去重的指纹算法。
+
+核心:
+ - normalize_content: 把 content 折叠成纯净文本,用于跨源比对
+ - content_hash: normalize 后 SHA1[:16]
+ - simhash64: 字符 3-gram + md5 加权累加,产出 64 位无符号整数
+ - hamming: 两个 SimHash 的汉明距离
+
+设计取舍:
+ SimHash 的"分词"用字符 3-gram 而非英文分词库。理由:
+ 1. 字符 3-gram 对英文和中文同样有效,无需外部 NLP 依赖
+ 2. 英文字符级 3-gram 天然捕获词根、前缀、后缀信息
+ 3. 跨语言场景(英文源可能引用中文/日文公司名)字符级更鲁棒
+"""
+
+import hashlib
+import unicodedata
+
+# 64 位 SimHash 位宽
+SIMHASH_BITS = 64
+SIMHASH_MASK = (1 << SIMHASH_BITS) - 1
+
+# 默认 SimHash 汉明距离阈值(≤ 此值视为重复)
+DEFAULT_HAMMING_THRESHOLD = 3
+
+# 字符 n-gram 长度
+NGRAM_SIZE = 3
+
+
+def normalize_content(text: str) -> str:
+ """把 content 折叠成"无空白无标点"形式,用于 L2 内容 hash 与 SimHash 输入。
+
+ 使用 Unicode 类别判断:
+ - P* Punctuation(所有中英文标点)
+ - Z* Separator(空格 / 行 / 段分隔符)
+ - C* Control(NUL / 换行控制等)
+ 保留 L*(字母)、N*(数字)、S*(符号,如 +/-、% 等),以及 CJK 字符。
+ """
+ if not text:
+ return ""
+ return "".join(
+ ch for ch in text if unicodedata.category(ch)[0] not in ("P", "Z", "C")
+ )
+
+
+def content_hash(text: str) -> str:
+ """对 normalize_content(text) 做 SHA1,取前 16 hex 字符。"""
+ norm = normalize_content(text)
+ return hashlib.sha1(norm.encode("utf-8")).hexdigest()[:16]
+
+
+def _ngrams(text: str, n: int = NGRAM_SIZE) -> list[str]:
+ """字符级 n-gram。文本短于 n 时,直接整体作为单个 token。"""
+ if len(text) < n:
+ return [text] if text else []
+ return [text[i : i + n] for i in range(len(text) - n + 1)]
+
+
+def simhash64(text: str) -> int:
+ """64 位 SimHash。返回无符号整数,空文本返回 0。"""
+ norm = normalize_content(text)
+ if not norm:
+ return 0
+
+ grams = _ngrams(norm)
+ if not grams:
+ return 0
+
+ v = [0] * SIMHASH_BITS
+ for gram in grams:
+ h = int(hashlib.md5(gram.encode("utf-8"), usedforsecurity=False).hexdigest(), 16)
+ # 取低 64 位
+ h64 = h & SIMHASH_MASK
+ for i in range(SIMHASH_BITS):
+ if (h64 >> i) & 1:
+ v[i] += 1
+ else:
+ v[i] -= 1
+
+ fp = 0
+ for i in range(SIMHASH_BITS):
+ if v[i] > 0:
+ fp |= 1 << i
+ return fp
+
+
+def hamming(a: int, b: int) -> int:
+ """两个 SimHash 的汉明距离。"""
+ return bin((a ^ b) & SIMHASH_MASK).count("1")
diff --git a/dedup/models.py b/dedup/models.py
new file mode 100644
index 0000000..2447606
--- /dev/null
+++ b/dedup/models.py
@@ -0,0 +1,57 @@
+"""三层去重模块的数据模型。"""
+
+from datetime import datetime
+from enum import StrEnum
+
+from pydantic import BaseModel, Field
+
+
+class DedupLayer(StrEnum):
+ """命中去重的层。"""
+
+ URL = "url" # L1: 完全相同 URL
+ CONTENT = "content" # L2: 标准化后 content 完全一致
+ SIMHASH = "simhash" # L3: SimHash 汉明距离 ≤ 阈值
+
+
+class Fingerprint(BaseModel):
+ """单篇文章的指纹记录,持久化到 SQLite。"""
+
+ url_hash: str = Field(..., description="主键,与 ProcessedArticle.url_hash 一致")
+ content_hash: str = Field(..., description="标准化 content 的 SHA1[:16]")
+ simhash: int = Field(..., description="64 位 SimHash 整数(无符号)")
+ source_id: str
+ url: str
+ title: str
+ publish_date: str | None = Field(default=None, description="YYYY-MM-DD,用于时间窗口")
+ ingested_at: datetime = Field(default_factory=datetime.now)
+
+
+class DedupResult(BaseModel):
+ """对单篇文章的判重结果。"""
+
+ url_hash: str
+ is_duplicate: bool
+ matched_layer: DedupLayer | None = None
+ matched_url_hash: str | None = None
+ matched_url: str | None = None
+ matched_title: str | None = None
+ hamming_distance: int | None = Field(
+ default=None, description="仅 SimHash 层有值"
+ )
+
+ def short_summary(self) -> str:
+ if not self.is_duplicate:
+ return f"[UNIQUE] {self.url_hash}"
+ layer = self.matched_layer.value if self.matched_layer else "?"
+ extra = f" hd={self.hamming_distance}" if self.hamming_distance is not None else ""
+ return f"[DUP/{layer}] {self.url_hash} ~ {self.matched_url_hash}{extra}"
+
+
+class DedupStats(BaseModel):
+ """指纹库统计。"""
+
+ total: int = 0
+ by_source: dict[str, int] = Field(default_factory=dict)
+ earliest: str | None = None
+ latest: str | None = None
diff --git a/dedup/pipeline.py b/dedup/pipeline.py
new file mode 100644
index 0000000..6ecab96
--- /dev/null
+++ b/dedup/pipeline.py
@@ -0,0 +1,226 @@
+"""批量去重管道:扫描 processed 目录 → 判重 → 唯一条目写入 deduped。
+
+输入: data/processed/{source_id}/{YYYYMMDD}/{url_hash}.json
+输出: data/deduped/{YYYYMMDD}/uniques/{url_hash}.json
+"""
+
+import json
+import logging
+from datetime import datetime
+from pathlib import Path
+
+from crawler.utils import get_news_day
+from dedup.deduper import Deduper
+from dedup.models import DedupResult
+from extractor.models import ProcessedArticle
+
+logger = logging.getLogger(__name__)
+
+
+def _get_processed_sources(base_dir: str = "data/processed") -> list[str]:
+ """扫描 data/processed/ 下所有源 ID。
+
+ Args:
+ base_dir: processed 数据根目录
+
+ Returns:
+ 源 ID 列表
+ """
+ raw_path = Path(base_dir)
+ if not raw_path.exists():
+ return []
+ return sorted([
+ d.name for d in raw_path.iterdir()
+ if d.is_dir() and not d.name.startswith(".")
+ ])
+
+
+def _load_processed_articles(
+ source_id: str,
+ date_str: str,
+) -> list[ProcessedArticle]:
+ """加载指定源/日期的已处理文章。
+
+ Args:
+ source_id: 新闻源 ID
+ date_str: 日期 YYYYMMDD
+
+ Returns:
+ ProcessedArticle 列表
+ """
+ base_dir = Path(f"data/processed/{source_id}/{date_str}")
+ if not base_dir.exists():
+ return []
+
+ articles: list[ProcessedArticle] = []
+ for json_file in sorted(base_dir.glob("*.json")):
+ # 跳过 index.jsonl
+ if json_file.name == "index.jsonl":
+ continue
+ try:
+ data = json.loads(json_file.read_text(encoding="utf-8"))
+ articles.append(ProcessedArticle(**data))
+ except (json.JSONDecodeError, Exception) as e:
+ logger.warning("解析 processed JSON 失败 %s: %s", json_file, e)
+
+ return articles
+
+
+def dedup_source(
+ source_id: str,
+ deduper: Deduper,
+ date_str: str | None = None,
+) -> dict:
+ """对单个源的已处理文章执行去重。
+
+ Args:
+ source_id: 新闻源 ID
+ deduper: 去重器实例
+ date_str: 日期 YYYYMMDD,默认当前新闻日
+
+ Returns:
+ 统计 dict
+ """
+ if date_str is None:
+ date_str = get_news_day()
+
+ logger.info("━━━ 去重 [%s] %s ━━━", source_id, date_str)
+
+ articles = _load_processed_articles(source_id, date_str)
+ if not articles:
+ logger.warning("[%s] %s 无待处理文章", source_id, date_str)
+ return {"source_id": source_id, "total": 0, "unique": 0, "duplicate": 0}
+
+ # 输出目录
+ out_dir = Path(f"data/deduped/{date_str}/uniques")
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ unique_count = 0
+ dup_count = 0
+
+ for article in articles:
+ result = deduper.ingest(article)
+
+ if result.is_duplicate:
+ dup_count += 1
+ logger.debug("[%s] 🔁 %s → L%d: %s",
+ source_id,
+ article.title[:40],
+ _layer_num(result),
+ result.short_summary())
+ else:
+ unique_count += 1
+ # 写入唯一条目
+ out_file = out_dir / f"{article.url_hash}.json"
+ out_file.write_text(
+ article.model_dump_json(indent=2, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ logger.debug("[%s] ✅ %s (%d words)",
+ source_id, article.title[:40], article.word_count)
+
+ logger.info("[%s] 去重完成: 唯一 %d / 重复 %d / 总计 %d",
+ source_id, unique_count, dup_count, len(articles))
+
+ return {
+ "source_id": source_id,
+ "total": len(articles),
+ "unique": unique_count,
+ "duplicate": dup_count,
+ }
+
+
+def _layer_num(result: DedupResult) -> int:
+ """DedupResult → 命中层编号。"""
+ if result.matched_layer is None:
+ return 0
+ mapping = {"url": 1, "content": 2, "simhash": 3}
+ return mapping.get(result.matched_layer.value, 0)
+
+
+def dedup_all_sources(
+ source_filter: str | None = None,
+ date_str: str | None = None,
+) -> dict:
+ """对所有源的已处理文章执行去重。
+
+ Args:
+ source_filter: 可选,只处理指定源
+ date_str: 日期,默认当前新闻日
+
+ Returns:
+ 统计 dict
+ """
+ if date_str is None:
+ date_str = get_news_day()
+
+ start_time = datetime.now()
+
+ if source_filter:
+ sources = [source_filter] if source_filter in _get_processed_sources() else []
+ else:
+ sources = _get_processed_sources()
+
+ logger.info("══════ 开始去重 %d 个源,日期: %s ══════", len(sources), date_str)
+
+ total_unique = 0
+ total_dup = 0
+ total_articles = 0
+
+ with Deduper() as deduper:
+ for src in sources:
+ result = dedup_source(src, deduper, date_str)
+ total_articles += result["total"]
+ total_unique += result["unique"]
+ total_dup += result["duplicate"]
+
+ # 输出 dedup 索引
+ _write_dedup_index(deduper, date_str, total_unique)
+
+ elapsed = (datetime.now() - start_time).total_seconds()
+ logger.info("══════ 去重完成: 唯一 %d / 重复 %d / 总计 %d,耗时 %.1f 秒 ══════",
+ total_unique, total_dup, total_articles, elapsed)
+
+ return {
+ "sources_processed": len(sources),
+ "total_articles": total_articles,
+ "unique": total_unique,
+ "duplicate": total_dup,
+ "elapsed_sec": elapsed,
+ "date": date_str,
+ }
+
+
+def _write_dedup_index(
+ deduper: Deduper,
+ date_str: str,
+ unique_count: int,
+) -> None:
+ """写出去重索引文件。
+
+ Args:
+ deduper: 去重器实例
+ date_str: 日期
+ unique_count: 唯一文章数
+ """
+ out_dir = Path(f"data/deduped/{date_str}")
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ stats = deduper.stats()
+
+ index_data = {
+ "date": date_str,
+ "unique_articles": unique_count,
+ "fingerprint_db_total": stats.total,
+ "fingerprint_db_by_source": stats.by_source,
+ "fingerprint_db_earliest": stats.earliest,
+ "fingerprint_db_latest": stats.latest,
+ "generated_at": datetime.now().isoformat(),
+ }
+
+ index_path = out_dir / "index.json"
+ index_path.write_text(
+ json.dumps(index_data, indent=2, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ logger.info("去重索引已写入: %s", index_path)
diff --git a/dedup/store.py b/dedup/store.py
new file mode 100644
index 0000000..55d791d
--- /dev/null
+++ b/dedup/store.py
@@ -0,0 +1,177 @@
+"""SQLite 指纹存储。
+
+注意: SimHash 是 64 位无符号整数,SQLite INTEGER 是 64 位有符号
+(范围 [-2^63, 2^63-1])。直接存可能溢出/转负数,虽然 XOR 仍然
+正确但语义混乱。这里统一存为 16 位 hex TEXT,避免符号问题。
+"""
+
+import logging
+import sqlite3
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+from dedup.models import Fingerprint
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_DB_PATH = Path("data/dedup/fingerprints.sqlite3")
+
+_SCHEMA_SQL = """
+CREATE TABLE IF NOT EXISTS fingerprints (
+ url_hash TEXT PRIMARY KEY,
+ content_hash TEXT NOT NULL,
+ simhash_hex TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ url TEXT NOT NULL,
+ title TEXT NOT NULL,
+ publish_date TEXT,
+ ingested_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_content_hash ON fingerprints(content_hash);
+CREATE INDEX IF NOT EXISTS idx_publish_date ON fingerprints(publish_date);
+CREATE INDEX IF NOT EXISTS idx_source_id ON fingerprints(source_id);
+"""
+
+
+def _to_hex(simhash: int) -> str:
+ """64 位无符号整数 → 16 位 hex 字符串。"""
+ return f"{simhash:016x}"
+
+
+def _from_hex(hex_str: str) -> int:
+ """16 位 hex 字符串 → 64 位无符号整数。"""
+ return int(hex_str, 16)
+
+
+def _row_to_fp(row: sqlite3.Row) -> Fingerprint:
+ """sqlite3.Row → Fingerprint 模型。"""
+ return Fingerprint(
+ url_hash=row["url_hash"],
+ content_hash=row["content_hash"],
+ simhash=_from_hex(row["simhash_hex"]),
+ source_id=row["source_id"],
+ url=row["url"],
+ title=row["title"],
+ publish_date=row["publish_date"],
+ ingested_at=datetime.fromisoformat(row["ingested_at"]),
+ )
+
+
+class FingerprintStore:
+ """SQLite 指纹库。线程不安全(每个线程请新建实例)。"""
+
+ def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None:
+ self.db_path = Path(db_path)
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._conn: sqlite3.Connection = sqlite3.connect(
+ self.db_path, isolation_level=None
+ )
+ self._conn.row_factory = sqlite3.Row
+ self._conn.executescript(_SCHEMA_SQL)
+ logger.debug("打开指纹库: %s", self.db_path)
+
+ def close(self) -> None:
+ self._conn.close()
+
+ def __enter__(self) -> "FingerprintStore":
+ return self
+
+ def __exit__(self, *_: Any) -> None:
+ self.close()
+
+ # ------------------------------------------------------------------ #
+ # 查询
+ # ------------------------------------------------------------------ #
+
+ def get_by_url_hash(self, url_hash: str) -> Fingerprint | None:
+ """按 url_hash 精确查询。"""
+ row = self._conn.execute(
+ "SELECT * FROM fingerprints WHERE url_hash = ?", (url_hash,)
+ ).fetchone()
+ return _row_to_fp(row) if row else None
+
+ def find_by_content_hash(self, content_hash: str) -> Fingerprint | None:
+ """返回任一 content_hash 匹配项。"""
+ row = self._conn.execute(
+ "SELECT * FROM fingerprints WHERE content_hash = ? LIMIT 1",
+ (content_hash,),
+ ).fetchone()
+ return _row_to_fp(row) if row else None
+
+ def candidates_for_simhash(
+ self,
+ publish_date: str | None,
+ window_days: int,
+ ) -> list[Fingerprint]:
+ """返回 publish_date ± window_days 内的指纹候选。
+
+ publish_date 为 None 时,不限定窗口(返回全部,慎用)。
+ """
+ if publish_date is None or window_days < 0:
+ rows = self._conn.execute("SELECT * FROM fingerprints").fetchall()
+ return [_row_to_fp(r) for r in rows]
+
+ try:
+ center = datetime.strptime(publish_date, "%Y-%m-%d")
+ except ValueError:
+ logger.debug("publish_date 不可解析: %r,退化为全表扫描", publish_date)
+ rows = self._conn.execute("SELECT * FROM fingerprints").fetchall()
+ return [_row_to_fp(r) for r in rows]
+
+ lo = (center - timedelta(days=window_days)).strftime("%Y-%m-%d")
+ hi = (center + timedelta(days=window_days)).strftime("%Y-%m-%d")
+ rows = self._conn.execute(
+ "SELECT * FROM fingerprints "
+ "WHERE publish_date IS NULL OR (publish_date >= ? AND publish_date <= ?)",
+ (lo, hi),
+ ).fetchall()
+ return [_row_to_fp(r) for r in rows]
+
+ def count(self) -> int:
+ """指纹总数。"""
+ return self._conn.execute("SELECT COUNT(*) FROM fingerprints").fetchone()[0]
+
+ def count_by_source(self) -> dict[str, int]:
+ """按 source_id 统计。"""
+ rows = self._conn.execute(
+ "SELECT source_id, COUNT(*) AS n FROM fingerprints GROUP BY source_id"
+ ).fetchall()
+ return {r["source_id"]: r["n"] for r in rows}
+
+ def date_range(self) -> tuple[str | None, str | None]:
+ """指纹库中最早和最晚的 publish_date。"""
+ row = self._conn.execute(
+ "SELECT MIN(publish_date) AS lo, MAX(publish_date) AS hi FROM fingerprints"
+ ).fetchone()
+ return (row["lo"], row["hi"]) if row else (None, None)
+
+ # ------------------------------------------------------------------ #
+ # 写入
+ # ------------------------------------------------------------------ #
+
+ def upsert(self, fp: Fingerprint) -> None:
+ """插入或替换指纹。"""
+ self._conn.execute(
+ "INSERT OR REPLACE INTO fingerprints "
+ "(url_hash, content_hash, simhash_hex, source_id, url, title, "
+ " publish_date, ingested_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (
+ fp.url_hash,
+ fp.content_hash,
+ _to_hex(fp.simhash),
+ fp.source_id,
+ fp.url,
+ fp.title,
+ fp.publish_date,
+ fp.ingested_at.isoformat(),
+ ),
+ )
+
+ def delete(self, url_hash: str) -> None:
+ """删除指定指纹。"""
+ self._conn.execute("DELETE FROM fingerprints WHERE url_hash = ?", (url_hash,))
+
+ def clear(self) -> None:
+ """清空指纹库,主要用于测试。"""
+ self._conn.execute("DELETE FROM fingerprints")
diff --git a/docs/intlnews_usage.html b/docs/intlnews_usage.html
new file mode 100644
index 0000000..1b831be
--- /dev/null
+++ b/docs/intlnews_usage.html
@@ -0,0 +1,661 @@
+
+
+
+
+
+国际财经 Deep Research 平台 — 使用手册 v1.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+1. 项目概述
+
+本项目构建面向国际英文财经新闻的私有化 Deep Research 平台。
+
+
+
+
✅ 核心能力
+
+ - 英文财经新闻抓取(Crawl4AI,12 源)
+ - 正文提取(trafilatura)
+ - 全文英译中(DeepSeek LLM)
+ - 投资事件抽取 + 美股代码识别
+ - 双语向量知识库(Qdrant 1024维)
+ - 中文语义检索
+ - MCP 服务(Agent 深度研究)
+ - 每日 AI 摘要日报(HTML)
+
+
+
+
❌ 本项目不是
+
+ - 自动交易系统
+ - 股票预测系统
+ - 投资顾问系统
+
+
+
+
+
+ 💡 对标项目:A 股 Deep Research(news/),架构模式可复用。
+
+
+
+2. 部署拓扑
+
+┌──────────────────────────────────────────────────┐
+│ Overseas Server (海外) │
+│ M1 Crawl4AI 抓取 → data/raw/ │
+│ 每天 4 次打包 → rsync 推送 │
+│ SSH: <海外服务器> │
+│ 路径: /opt/intlgrab │
+└────────────────────┬─────────────────────────────┘
+ │ rsync
+ ▼
+┌──────────────────────────────────────────────────┐
+│ Domestic Server (国内) │
+│ M2 正文提取 → M3 去重 → M4 翻译+事件 │
+│ → M5 向量生成 → M6 Qdrant 入库 │
+│ → M7 调度 + 日报 → M8 MCP 服务 │
+│ SSH: <国内服务器> │
+│ 路径: /home/pi/intlnews │
+└──────────────────────────────────────────────────┘
+
+
+3. 环境配置
+
+3.1 依赖安装
+cd /home/pi/intlnews
+uv sync
+
+3.2 配置文件
+
+
+ | 文件 | 用途 | 注意 |
+ .env | API Key / URL | 不入 Git,从 .env.example 复制 |
+ configs/system.yaml | 功能参数 | 模型名、阈值、超时、调度 |
+ configs/sources.yaml | 新闻源定义 | 12 个英文财经源 |
+
+
+3.3 必需环境变量
+
+# DeepSeek(M4 翻译+事件抽取)
+DEEPSEEK_API_KEY=sk-your-key
+DEEPSEEK_BASE_URL=https://api.deepseek.com
+
+# Qwen(备选 LLM)
+QWEN_API_KEY=sk-your-key
+QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+
+# DashScope(M5 向量生成)
+DASHSCOPE_API_KEY=sk-your-key
+
+# Qdrant(留空使用本地文件模式)
+QDRANT_URL=http://localhost:6333
+QDRANT_API_KEY=
+
+3.4 关键配置项
+
+# configs/system.yaml(关键项)
+crawler.max_memory_mb: 1800 # 内存上限
+dedup.hamming_distance_threshold: 3
+dedup.simhash_window_days: 30
+llm.provider: "deepseek"
+llm.deepseek_model: "deepseek-v4-flash"
+llm.concurrency: 3
+embedding.dashscope_model: "text-embedding-v3"
+embedding.dimension: 1024
+qdrant.collection: "en_finance_news"
+schedule.day_cutoff_hour: 6 # 新闻日切分点
+
+
+4. CLI 命令参考
+
+所有命令通过 uv run en-news 执行:
+
+
+ | 命令 | Milestone | 功能 |
+ crawl | M1 | 抓取英文财经新闻 |
+ extract | M2 | 正文提取 |
+ dedup | M3 | 三层去重 |
+ translate | M4 | 翻译 + 事件抽取 |
+ embed | M5 | 向量生成 |
+ index | M6 | Qdrant 入库 |
+ search <query> | M6 | 语义检索 |
+ pipeline | M7 | 一键全链路 M2→M6 |
+ report | M7 | 日报生成 |
+ mcp-server | M8 | 启动 MCP 服务 |
+
+
+# 常用选项
+uv run en-news crawl --source forexlive # 指定源
+uv run en-news search "美联储" --top-k 5 # 指定返回数
+uv run en-news index --recreate # 重建 Qdrant
+uv run en-news pipeline --skip-report # 跳过日报
+
+
+5. M1 — 新闻抓取
+
+5.1 手动抓取
+uv run en-news crawl # 全部源
+uv run en-news crawl -s forexlive # 指定源
+
+5.2 12 个英文财经源
+
+
+ | 源 ID | 名称 | 类型 |
+ | reuters | Reuters | 综合财经 |
+ | cnbc | CNBC | 市场新闻 |
+ | marketwatch | MarketWatch | 市场数据 |
+ | ft | Financial Times | 财经深度 |
+ | yahoo_finance | Yahoo Finance | 综合 |
+ | investing | Investing.com | 全球市场 |
+ | seekingalpha | Seeking Alpha | 投资分析 |
+ | barrons | Barrons | 市场评论 |
+ | wsj | WSJ | 综合财经 |
+ | economist | The Economist | 经济分析 |
+ | forexlive | ForexLive | 外汇新闻 |
+ | zerohedge | ZeroHedge | 另类财经 |
+
+
+5.3 增量抓取机制
+
+抓取采用两层增量确保不重复下载和存储:
+
+
+ | 层级 | 位置 | 机制 |
+ | 抓取层 | _extract_article_urls | 读取当日 index.jsonl 中已抓取的 url_hash,跳过已存在的 URL,不重复下载 |
+ | 存储层 | write_index_jsonl | 追加写入前再次检查 url_hash,已存在则跳过 |
+
+
+同一天内多次执行 crawl,只有新文章才会被下载和存储。
+
+5.4 产物
+data/raw/{source_id}/{YYYYMMDD}/
+├── {url_hash}.html # 原始 HTML
+├── {url_hash}.md # Crawl4AI Markdown
+└── index.jsonl # 文章索引
+
+5.5 反爬策略
+
+部分新闻源有反爬保护(如 DataDome)。系统支持三种策略,按优先级自动选择:
+
+
+ | 优先级 | 策略 | 配置 | 说明 |
+ | 1 | RSS 抓取 | rss_url | 通过 Feed 获取文章,完全绕过反爬 ✅ MarketWatch |
+ | 2 | Stealth | anti_bot_mode: "stealth" | 隐藏 webdriver 特征 |
+ | 3 | Headful | anti_bot_mode: "headful" | 非 headless 浏览器,最像真人 |
+
+
+已验证:MarketWatch RSS 10/10 ✅ · WSJ RSS 20/20 ✅ · ForexLive 17/17 ✅ · ZeroHedge 15/15 ✅ · Barron's RSS 10/10 ✅ · Reuters ⚠️ · SeekingAlpha ⚠️
+
+
+6. M2 — 正文提取
+
+uv run en-news extract # 全部源
+uv run en-news extract -s forexlive # 指定源
+
+技术方案:优先 Crawl4AI Markdown → 回退 trafilatura(英文优化)。最小正文 50 词。
+
+data/processed/{source_id}/{YYYYMMDD}/
+└── {url_hash}.json # ProcessedArticle
+
+
+7. M3 — 三层去重
+
+uv run en-news dedup
+
+
+ | 层级 | 方法 | 说明 |
+ | L1 | URL Hash | 完全相同 URL |
+ | L2 | 内容 Hash | SHA1[:16] 去标点空白后匹配 |
+ | L3 | SimHash | 字符 3-gram,汉明距离 ≤ 3,30 天窗口 |
+
+
+data/dedup/fingerprints.sqlite3 # SQLite 指纹库
+data/deduped/{YYYYMMDD}/uniques/ # 唯一文章
+
+
+8. M4 — 翻译 + 事件抽取
+
+uv run en-news translate
+
+
+
⚙️ 技术规格
+
+ - Provider: DeepSeek v4-flash(默认)/ Qwen 备选
+ - 单次调用完成翻译+抽取,节省 token
+ - 并发 3 线程,3 次指数退避重试
+ - 正文截断 8000 字符
+
+
+
+14 种事件类型
+
+ 财报披露
+ 并购收购
+ 产品发布
+ 监管政策
+ 宏观经济
+ 央行决议
+ 行业动态
+ 技术突破
+ 高管变动
+ 诉讼法律
+ 市场异动
+ 地缘政治
+ 大宗商品
+ 外汇波动
+
+
+情绪标签
+
+ 🟢 positive 利好
+ ⚪ neutral 中性
+ 🔴 negative 利空
+
+
+
+9. M5 — 向量生成
+
+uv run en-news embed
+
+技术方案:DashScope text-embedding-v3,1024 维。嵌入文本 = 中文标题 + 事件标签 + 中文正文(4000 字符截断)。
+
+data/embeddings/{YYYYMMDD}/
+└── {url_hash}.json # EmbeddingResult (1024 floats)
+
+
+10. M6 — Qdrant 入库与检索
+
+入库
+uv run en-news index # 增量入库
+uv run en-news index --recreate # 重建 collection
+
+语义搜索
+uv run en-news search "美联储利率决议"
+uv run en-news search "伊朗霍尔木兹海峡" --top-k 5
+
+
+
📦 Qdrant 规格
+
+ - 模式: 本地文件(
data/qdrant_storage/),无需 Docker
+ - Collection:
en_finance_news
+ - 距离: Cosine · 维度: 1024
+ - Payload: title / title_zh / url / events / content_zh_preview
+
+
+
+
+11. M7 — 全链路管道 + 日报
+
+手动全链路运行(完整流程)
+
+当需要手工执行完整的数据处理流程时,按顺序执行:
+
+# 步骤 1(海外): 抓取英文财经新闻
+ssh <海外服务器> "cd /opt/intlgrab && uv run en-news crawl"
+
+# 步骤 2(海外): 打包 raw 数据
+ssh <海外服务器> "cd /opt/intlgrab && bash scripts/overseas_pack.sh"
+
+# 步骤 3(国内): 拉取海外数据
+cd /home/pi/intlnews && bash scripts/domestic_sync.sh
+
+# 步骤 4: 一键全链路 M2→M6 + 日报
+cd /home/pi/intlnews && uv run en-news pipeline
+
+也可以分步执行(适合调试):
+
+uv run en-news extract # M2: 正文提取
+uv run en-news dedup # M3: 三层去重
+uv run en-news translate # M4: 翻译 + 事件抽取
+uv run en-news embed # M5: 向量生成
+uv run en-news index # M6: Qdrant 入库
+uv run en-news report # 日报生成
+
+一键管道(自动)
+uv run en-news pipeline # 全链路 M2→M6 + 日报
+uv run en-news pipeline --skip-report # 跳过日报
+uv run en-news report # 仅日报
+
+管道流程
+
+ M2 extract
+ →
+ M3 dedup
+ →
+ M4 translate
+ →
+ M5 embed
+ →
+ M6 index
+ →
+ 📰 日报
+
+
+
+ ⚠️ 降级策略:每步失败记录日志但不阻断后续步骤。
+
+
+HTML 日报五板块
+
+ - 🤖 AI 摘要 — LLM 根据 important≥4 事件生成要点总结
+ - 🔥 重要事件 — 事件表格(情绪/重要度/摘要/链接)
+ - 📊 数据总览 — M1→M6 管道统计数字
+ - 📈 情绪分布 — 利好/利空/中性比例条
+ - 📋 事件类型 TOP 10
+
+
+本地: data/reports/intl_news_daily_{YYYYMMDD}.html
+线上: 自动上传到 https://echart.doorcome.cn/research/{YYYYMMDD}/
+上传配置在 configs/system.yaml → report.upload_host / report.upload_path
+
+
+12. M8 — MCP 服务
+
+启动
+uv run en-news mcp-server
+
+5 个 MCP Tool
+
+
+ | Tool | 功能 | 示例调用 |
+ search_news | 语义检索新闻 | search_news("美联储利率决议") |
+ search_by_stock | 美股代码检索 | search_by_stock("AAPL") |
+ search_by_sentiment | 情绪过滤检索 | search_by_sentiment("加息", sentiment="negative") |
+ get_today_events | 当日重要事件 | get_today_events(importance_min=4) |
+ get_stats | 系统统计 | get_stats() |
+
+
+Claude Code MCP 配置
+{
+ "mcpServers": {
+ "intl-news": {
+ "command": "uv",
+ "args": ["run", "en-news", "mcp-server"],
+ "cwd": "/home/pi/intlnews"
+ }
+ }
+}
+
+
+13. 数据目录结构
+
+data/
+├── raw/ # M1: 原始抓取
+│ └── {source_id}/{YYYYMMDD}/
+├── processed/ # M2: 正文提取
+│ └── {source_id}/{YYYYMMDD}/
+├── dedup/ # M3: 指纹库 + 去重结果
+│ ├── fingerprints.sqlite3
+│ └── {YYYYMMDD}/uniques/
+├── events/ # M4: 翻译+事件
+│ └── {YYYYMMDD}/
+├── embeddings/ # M5: 向量
+│ └── {YYYYMMDD}/
+├── qdrant_storage/ # M6: Qdrant 本地存储
+└── reports/ # M7: 日报
+ └── intl_news_daily_{YYYYMMDD}.html
+
+
+14. 定时任务
+
+时间线
+
+
+ | 时间 | 服务器 | 动作 |
+ | 05:55 | 海外 | 打包 data/raw/ |
+ | 06:30 | 国内 | 拉取 → M2→M6 管道 → 📰 日报 |
+ | 11:55 | 海外 | 打包 |
+ | 12:00 | 国内 | 拉取 → 管道 |
+ | 17:55 | 海外 | 打包 |
+ | 18:00 | 国内 | 拉取 → 管道 |
+ | 21:55 | 海外 | 打包 |
+ | 22:00 | 国内 | 拉取 → 管道 → 📰 日报 |
+
+
+新闻日定义
+切分点 day_cutoff_hour: 6。当天 06:00 至次日 05:59 属于同一个新闻日。
+
+
+15. 常见问题
+
+Q: 如何新增新闻源?
+编辑 configs/sources.yaml:
+- id: "new_source"
+ name: "New Source Name"
+ enabled: true
+ homepage: "https://example.com/finance/"
+ article_url_pattern: "/news/[^/]+/"
+ js_render: false
+ max_articles_per_run: 30
+
+Q: 翻译质量不好怎么办?
+
+ - 调整
configs/system.yaml 中 llm.temperature(降低更保守)
+ - 编辑
prompts/translation_and_extraction.md 优化 Prompt
+ - 切换 Provider:
llm.provider: "qwen"
+
+
+Q: Qdrant 检索太慢?
+本地文件模式 17 条 < 0.01s,足够快。数据量 > 10 万条时切换到 Docker 模式。
+
+Q: 如何查看日志?
+tail -f logs/sync.log
+tail -f logs/crawler.log # 海外
+
+Q: 数据如何备份?
+tar czf intlnews_backup_$(date +%Y%m%d).tar.gz data/
+
+
+附录:技术栈
+
+
+ | 组件 | 技术 |
+ | 语言 | Python 3.11 |
+ | 包管理 | uv + pyproject.toml |
+ | 抓取 | Crawl4AI + Playwright |
+ | 正文提取 | trafilatura |
+ | 去重 | SimHash + SQLite |
+ | LLM | DeepSeek v4-flash(OpenAI SDK) |
+ | Embedding | DashScope text-embedding-v3 |
+ | 向量库 | Qdrant(本地文件模式) |
+ | MCP | FastMCP |
+ | CLI | Typer |
+ | 配置 | YAML + .env |
+ | 数据模型 | Pydantic v2 |
+
+
+
+
+
+
+
+
diff --git a/docs/intlnews_usage.md b/docs/intlnews_usage.md
new file mode 100644
index 0000000..c01d582
--- /dev/null
+++ b/docs/intlnews_usage.md
@@ -0,0 +1,672 @@
+# 国际财经 Deep Research 平台 — 使用手册
+
+> 版本:v1.0
+> 最后更新:2026-06-21
+> 项目路径:国内 `/home/pi/intlnews` / 海外 `/opt/intlgrab`
+
+---
+
+## 目录
+
+1. [项目概述](#1-项目概述)
+2. [部署拓扑](#2-部署拓扑)
+3. [环境配置](#3-环境配置)
+4. [CLI 命令参考](#4-cli-命令参考)
+5. [M1 — 新闻抓取](#5-m1--新闻抓取)
+6. [M2 — 正文提取](#6-m2--正文提取)
+7. [M3 — 三层去重](#7-m3--三层去重)
+8. [M4 — 翻译 + 事件抽取](#8-m4--翻译--事件抽取)
+9. [M5 — 向量生成](#9-m5--向量生成)
+10. [M6 — Qdrant 入库与检索](#10-m6--qdrant-入库与检索)
+11. [M7 — 全链路管道 + 日报](#11-m7--全链路管道--日报)
+12. [M8 — MCP 服务](#12-m8--mcp-服务)
+13. [数据目录结构](#13-数据目录结构)
+14. [定时任务](#14-定时任务)
+15. [常见问题](#15-常见问题)
+
+---
+
+## 1. 项目概述
+
+本项目构建面向**国际英文财经新闻**的私有化 Deep Research 平台。
+
+核心能力:
+
+- 英文财经新闻抓取(Crawl4AI,12 个源)
+- 正文提取(trafilatura)
+- 全文英译中(DeepSeek LLM)
+- 投资事件抽取(美股代码识别 + 情绪判断 + 重要度评分)
+- 双语向量知识库(Qdrant,1024 维)
+- 语义检索(中文自然语言)
+- MCP 服务(Claude Code / Cherry Studio Agent 深度研究)
+- 每日 AI 摘要日报(HTML)
+
+**本项目不是**:交易系统 / 股票预测系统 / 投资顾问系统。
+
+---
+
+## 2. 部署拓扑
+
+```
+┌──────────────────────────────────────────────────┐
+│ Overseas Server (海外) │
+│ M1 Crawl4AI 抓取 → data/raw/ │
+│ 每天 4 次打包 → rsync 推送 │
+│ SSH: <海外服务器> │
+│ 路径: /opt/intlgrab │
+└────────────────────┬─────────────────────────────┘
+ │ rsync
+ ▼
+┌──────────────────────────────────────────────────┐
+│ Domestic Server (国内) │
+│ M2 正文提取 → M3 去重 → M4 翻译+事件 │
+│ → M5 向量生成 → M6 Qdrant 入库 │
+│ → M7 调度 + 日报 → M8 MCP 服务 │
+│ SSH: <国内服务器> │
+│ 路径: /home/pi/intlnews │
+└──────────────────────────────────────────────────┘
+```
+
+---
+
+## 3. 环境配置
+
+### 3.1 依赖安装
+
+```bash
+cd /home/pi/intlnews
+uv sync
+```
+
+### 3.2 配置文件
+
+| 文件 | 用途 | 示例 |
+|------|------|------|
+| `.env` | API Key / URL(不入 Git) | `DEEPSEEK_API_KEY=sk-xxx` |
+| `configs/system.yaml` | 功能参数(模型、阈值、超时) | `llm.provider: deepseek` |
+| `configs/sources.yaml` | 新闻源定义 | 12 个英文财经源 |
+
+### 3.3 必需环境变量(`.env`)
+
+```bash
+# DeepSeek(M4 翻译+事件抽取)
+DEEPSEEK_API_KEY=sk-your-key
+DEEPSEEK_BASE_URL=https://api.deepseek.com
+
+# Qwen(备选 LLM)
+QWEN_API_KEY=sk-your-key
+QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+
+# DashScope(M5 向量生成)
+DASHSCOPE_API_KEY=sk-your-key
+
+# Qdrant(留空使用本地文件模式)
+QDRANT_URL=http://localhost:6333
+QDRANT_API_KEY=
+```
+
+### 3.4 关键配置项(`configs/system.yaml`)
+
+```yaml
+crawler:
+ max_memory_mb: 1800 # 串行抓取内存上限
+
+dedup:
+ hamming_distance_threshold: 3 # SimHash 汉明距离阈值
+ simhash_window_days: 30 # 时间窗口
+
+llm:
+ provider: "deepseek"
+ deepseek_model: "deepseek-v4-flash"
+ concurrency: 3 # LLM 并发数
+
+embedding:
+ provider: "dashscope"
+ dashscope_model: "text-embedding-v3"
+ dimension: 1024
+
+qdrant:
+ collection: "en_finance_news"
+
+schedule:
+ day_cutoff_hour: 6 # 新闻日切分点(06:00)
+```
+
+---
+
+## 4. CLI 命令参考
+
+所有命令通过 `uv run en-news` 执行:
+
+| 命令 | Milestone | 功能 |
+|------|-----------|------|
+| `crawl` | M1 | 抓取英文财经新闻 |
+| `extract` | M2 | 正文提取 |
+| `dedup` | M3 | 三层去重 |
+| `translate` | M4 | 翻译 + 事件抽取 |
+| `embed` | M5 | 向量生成 |
+| `index` | M6 | Qdrant 入库 |
+| `search ` | M6 | 语义检索 |
+| `pipeline` | M7 | 一键全链路 M2→M6 |
+| `report` | M7 | 日报生成 |
+| `mcp-server` | M8 | 启动 MCP 服务 |
+
+常用选项:
+
+```bash
+# 指定源
+uv run en-news crawl --source forexlive
+uv run en-news extract --source forexlive
+
+# 指定 top_k
+uv run en-news search "美联储利率决议" --top-k 5
+
+# 重建 Qdrant collection
+uv run en-news index --recreate
+
+# 全链路跳过日报
+uv run en-news pipeline --skip-report
+```
+
+---
+
+## 5. M1 — 新闻抓取
+
+### 5.1 手动抓取
+
+```bash
+# 抓取所有启用的源
+uv run en-news crawl
+
+# 只抓取指定源
+uv run en-news crawl -s forexlive
+```
+
+### 5.2 新闻源列表
+
+| 源 ID | 名称 | 类型 |
+|-------|------|------|
+| reuters | Reuters | 综合财经 |
+| cnbc | CNBC | 市场新闻 |
+| marketwatch | MarketWatch | 市场数据 |
+| ft | Financial Times | 财经深度 |
+| yahoo_finance | Yahoo Finance | 综合 |
+| investing | Investing.com | 全球市场 |
+| seekingalpha | Seeking Alpha | 投资分析 |
+| barrons | Barrons | 市场评论 |
+| wsj | WSJ | 综合财经 |
+| economist | The Economist | 经济分析 |
+| forexlive | ForexLive | 外汇新闻 |
+| zerohedge | ZeroHedge | 另类财经 |
+
+### 5.3 反爬策略
+
+部分新闻源设有反爬保护(如 DataDome)。系统支持三种策略,按优先级自动选择:
+
+| 优先级 | 策略 | 配置字段 | 说明 | 适用源 |
+|--------|------|---------|------|--------|
+| 1 | **RSS 抓取** | `rss_url` | 通过 RSS/Atom Feed 获取文章列表,完全绕过反爬 | MarketWatch ✅ |
+| 2 | **Stealth 模式** | `anti_bot_mode: "stealth"` | 隐藏 webdriver 特征(`--disable-blink-features=AutomationControlled`) | Reuters(海外内存不足待验证) |
+| 3 | **Headful 模式** | `anti_bot_mode: "headful"` | 非 headless 浏览器,最像真人 | 重度反爬源回退 |
+
+配置示例(`configs/sources.yaml`):
+
+```yaml
+- id: "marketwatch"
+ rss_url: "https://feeds.marketwatch.com/marketwatch/topstories" # RSS 优先
+ anti_bot_mode: "headful" # RSS 失败时回退
+
+- id: "reuters"
+ anti_bot_mode: "stealth" # 无 RSS,直接 stealth
+```
+
+**已验证**:
+
+| 源 | 方式 | 结果 |
+|----|------|------|
+| MarketWatch | RSS | ✅ 10/10 |
+| WSJ | RSS | ✅ 20/20 |
+| ForexLive | 标准 headless | ✅ 17/17 |
+| ZeroHedge | 标准 + URL 过滤 | ✅ 15/15 |
+| Barron's | RSS (Dow Jones) | ✅ 10/10 |
+| Reuters | stealth | ⚠️ DataDome |
+| SeekingAlpha | stealth | ⚠️ PerimeterX |
+
+### 5.4 海外定时抓取
+
+```cron
+# crontab(<海外服务器>)— 每天 4 次
+# 时序: 抓取(60min) → 打包(5min) → 10min后国内拉取
+0 6 * * * cd /opt/intlgrab && bash scripts/overseas_crawl.sh
+5 7 * * * cd /opt/intlgrab && bash scripts/overseas_pack.sh
+0 12 * * * cd /opt/intlgrab && bash scripts/overseas_crawl.sh
+5 13 * * * cd /opt/intlgrab && bash scripts/overseas_pack.sh
+0 18 * * * cd /opt/intlgrab && bash scripts/overseas_crawl.sh
+5 19 * * * cd /opt/intlgrab && bash scripts/overseas_pack.sh
+0 22 * * * cd /opt/intlgrab && bash scripts/overseas_crawl.sh
+5 23 * * * cd /opt/intlgrab && bash scripts/overseas_pack.sh
+```
+
+### 5.4 增量抓取机制
+
+抓取采用**两层增量**确保不重复下载和存储:
+
+| 层级 | 位置 | 机制 |
+|------|------|------|
+| 抓取层 | `_extract_article_urls` | 读取当日 `index.jsonl` 中已抓取的 url_hash,跳过已存在的 URL,不重复下载 |
+| 存储层 | `write_index_jsonl` | 追加写入前再次检查 url_hash,已存在则跳过 |
+
+同一天内多次执行 `crawl`,只有新文章才会被下载和存储。
+
+### 5.5 产物
+
+```
+data/raw/{source_id}/{YYYYMMDD}/
+├── {url_hash}.html # 原始 HTML
+├── {url_hash}.md # Crawl4AI Markdown
+└── index.jsonl # 文章索引
+```
+
+---
+
+## 6. M2 — 正文提取
+
+### 6.1 执行
+
+```bash
+# 提取所有源
+uv run en-news extract
+
+# 提取指定源
+uv run en-news extract -s forexlive
+```
+
+### 6.2 技术方案
+
+- 优先使用 Crawl4AI 输出的 Markdown
+- 回退 `trafilatura` 英文正文提取
+- 最小正文字数阈值:50 词
+
+### 6.3 产物
+
+```
+data/processed/{source_id}/{YYYYMMDD}/
+├── {url_hash}.json # ProcessedArticle
+└── index.jsonl # 处理索引
+```
+
+---
+
+## 7. M3 — 三层去重
+
+### 7.1 执行
+
+```bash
+uv run en-news dedup
+```
+
+### 7.2 去重逻辑
+
+| 层级 | 方法 | 说明 |
+|------|------|------|
+| L1 | URL Hash | 完全相同 URL 直接命中 |
+| L2 | 内容 Hash | 标准化后 SHA1[:16] 匹配(去标点/空白) |
+| L3 | SimHash | 字符 3-gram,汉明距离 ≤ 3,30 天窗口 |
+
+### 7.3 产物
+
+```
+data/deduped/{YYYYMMDD}/
+├── uniques/{url_hash}.json # 唯一文章
+└── index.json # 去重索引
+data/dedup/fingerprints.sqlite3 # 指纹库
+```
+
+---
+
+## 8. M4 — 翻译 + 事件抽取
+
+### 8.1 执行
+
+```bash
+uv run en-news translate
+```
+
+### 8.2 技术方案
+
+- **Provider**: DeepSeek v4-flash(默认)/ Qwen 备选
+- **单次调用**:翻译 + 事件抽取合并,节省 token
+- **并发**:3 线程(`system.yaml` → `llm.concurrency`)
+- **重试**:3 次指数退避(1s → 2s → 4s)
+
+### 8.3 输出格式
+
+```json
+{
+ "title": "Fed Holds Rates Steady as Markets Rally",
+ "title_zh": "美联储维持利率不变,市场上涨",
+ "content_en": "The Federal Reserve held...",
+ "content_zh": "美联储周三维持利率不变...",
+ "events": [
+ {
+ "event_type": "央行决议",
+ "stock_codes": [],
+ "sentiment": "neutral",
+ "importance": 5,
+ "summary_zh": "美联储维持利率不变,市场反弹"
+ }
+ ]
+}
+```
+
+### 8.4 14 种事件类型
+
+`财报披露` `并购收购` `产品发布` `监管政策` `宏观经济`
+`央行决议` `行业动态` `技术突破` `高管变动` `诉讼法律`
+`市场异动` `地缘政治` `大宗商品` `外汇波动` `其他`
+
+### 8.5 产物
+
+```
+data/events/{YYYYMMDD}/
+├── {url_hash}.json # EnTranslatedArticle
+└── index.json # 事件索引
+```
+
+---
+
+## 9. M5 — 向量生成
+
+### 9.1 执行
+
+```bash
+uv run en-news embed
+```
+
+### 9.2 技术方案
+
+- **Provider**: DashScope `text-embedding-v3`
+- **维度**: 1024
+- **嵌入文本**: `标题: {title_zh}` + `事件: [{sentiment}] {event_type} 重要度{n} {summary_zh}` + `正文: {content_zh[:3000]}`
+- **截断**: 4000 字符上限
+
+### 9.3 产物
+
+```
+data/embeddings/{YYYYMMDD}/
+├── {url_hash}.json # EmbeddingResult(1024 维)
+└── index.json # 向量索引
+```
+
+---
+
+## 10. M6 — Qdrant 入库与检索
+
+### 10.1 入库
+
+```bash
+# 增量入库
+uv run en-news index
+
+# 重建 collection(清空旧数据)
+uv run en-news index --recreate
+```
+
+### 10.2 语义搜索
+
+```bash
+# 基本搜索
+uv run en-news search "美联储利率决议"
+
+# 指定返回条数
+uv run en-news search "伊朗霍尔木兹海峡" --top-k 5
+```
+
+### 10.3 技术方案
+
+- **模式**: 本地文件(`data/qdrant_storage/`),无需 Docker
+- **Collection**: `en_finance_news`
+- **距离**: Cosine
+- **Payload**: title / title_zh / url / source_id / events / content_zh_preview
+
+### 10.4 产物
+
+```
+data/qdrant_storage/ # Qdrant 本地文件存储
+```
+
+---
+
+## 11. M7 — 全链路管道 + 日报
+
+### 11.1 手动全链路运行(完整流程)
+
+当需要手工执行完整的数据处理流程时,按顺序执行以下命令:
+
+```bash
+# 步骤 1(海外): 抓取英文财经新闻
+ssh <海外服务器> "cd /opt/intlgrab && uv run en-news crawl"
+
+# 步骤 2(海外): 打包 raw 数据
+ssh <海外服务器> "cd /opt/intlgrab && bash scripts/overseas_pack.sh"
+
+# 步骤 3(国内): 拉取海外数据
+cd /home/pi/intlnews && bash scripts/domestic_sync.sh
+
+# 步骤 4: 一键全链路 M2→M6 + 日报
+cd /home/pi/intlnews && uv run en-news pipeline
+```
+
+也可以分步执行(适合调试):
+
+```bash
+# 分步模式
+uv run en-news extract # M2: 正文提取
+uv run en-news dedup # M3: 三层去重
+uv run en-news translate # M4: 翻译 + 事件抽取
+uv run en-news embed # M5: 向量生成
+uv run en-news index # M6: Qdrant 入库
+uv run en-news report # 日报生成
+```
+
+### 11.2 一键管道(自动)
+
+```bash
+# 全链路 M2→M6 + 日报
+uv run en-news pipeline
+
+# 跳过日报生成
+uv run en-news pipeline --skip-report
+
+# 只生成日报
+uv run en-news report
+```
+
+### 11.2 管道步骤
+
+```
+M2 extract → M3 dedup → M4 translate → M5 embed → M6 index → 日报
+```
+
+每步失败记录日志但不阻断后续步骤(降级继续)。
+
+### 11.3 HTML 日报
+
+日报包含五个板块:
+
+1. 🤖 **AI 摘要** — LLM 根据当日 important≥4 事件生成要点总结
+2. 🔥 **重要事件** — 高重要度事件表格(标题/情绪/重要度/摘要/链接)
+3. 📊 **数据总览** — M1→M6 管道统计数据
+4. 📈 **情绪分布** — 利好/利空/中性比例条 + 重要度分布
+5. 📋 **事件类型 TOP 10**
+
+日报输出:`data/reports/intl_news_daily_{YYYYMMDD}.html`(约 9KB),同时自动上传到
+`https://echart.doorcome.cn/research/{YYYYMMDD}/intl_news_daily_{YYYYMMDD}.html`
+
+### 11.4 国内定时调度
+
+```cron
+# crontab(<国内服务器>)— 每天 3 次
+# 全流程:SSH 触发海外打包 → 下载 → 管道串行 M2→M6 → 日报
+0 7 * * * cd /home/pi/intlnews && bash scripts/domestic_full.sh
+0 12 * * * cd /home/pi/intlnews && bash scripts/domestic_full.sh
+0 18 * * * cd /home/pi/intlnews && bash scripts/domestic_full.sh
+```
+
+`domestic_full.sh` 统一完成:远程打包 → 同步 → 管道 → 日报。
+
+---
+
+## 12. M8 — MCP 服务
+
+### 12.1 启动
+
+```bash
+uv run en-news mcp-server
+```
+
+### 12.2 可用 Tool
+
+| Tool | 功能 | 示例 |
+|------|------|------|
+| `search_news` | 语义检索新闻 | `search_news("美联储利率决议")` |
+| `search_by_stock` | 美股代码检索 | `search_by_stock("AAPL")` |
+| `search_by_sentiment` | 按情绪检索 | `search_by_sentiment("加息", sentiment="negative")` |
+| `get_today_events` | 当日重要事件 | `get_today_events(importance_min=4)` |
+| `get_stats` | 系统统计 | `get_stats()` |
+
+### 12.3 Claude Code 配置
+
+在 Claude Code 的 MCP 配置中添加:
+
+```json
+{
+ "mcpServers": {
+ "intl-news": {
+ "command": "uv",
+ "args": ["run", "en-news", "mcp-server"],
+ "cwd": "/home/pi/intlnews"
+ }
+ }
+}
+```
+
+---
+
+## 13. 数据目录结构
+
+```
+data/
+├── raw/ # M1: 原始抓取
+│ └── {source_id}/{YYYYMMDD}/
+│ ├── {url_hash}.html
+│ ├── {url_hash}.md
+│ └── index.jsonl
+├── processed/ # M2: 正文提取
+│ └── {source_id}/{YYYYMMDD}/
+│ └── {url_hash}.json
+├── dedup/ # M3: 指纹库
+│ ├── fingerprints.sqlite3
+│ └── {YYYYMMDD}/
+│ ├── uniques/{url_hash}.json
+│ └── index.json
+├── events/ # M4: 翻译+事件
+│ └── {YYYYMMDD}/
+│ └── {url_hash}.json
+├── embeddings/ # M5: 向量
+│ └── {YYYYMMDD}/
+│ └── {url_hash}.json
+├── qdrant_storage/ # M6: Qdrant 本地存储
+└── reports/ # M7: 日报
+ └── intl_news_daily_{YYYYMMDD}.html
+```
+
+---
+
+## 14. 定时任务
+
+### 14.1 时间线
+
+```
+海外 国内
+───────────────────────────── ─────────────────────────
+06:00 crawl (≈60min) 07:00 全流程(打包→下载→管道→日报)
+12:00 crawl (≈60min) 12:00 全流程
+18:00 crawl (≈60min) 18:00 全流程
+22:00 crawl (≈60min) (夜间 crawl 结果次日 07:00 处理)
+```
+
+国内 `domestic_full.sh` 流程:`SSH触发海外打包 → 下载 → M2→M3→M4→M5→M6 → 日报`(串行)。
+
+### 14.2 新闻日定义
+
+- 切分点:`day_cutoff_hour: 6`(凌晨 06:00)
+- 当天 06:00 至次日 05:59 属于同一个新闻日
+- 例如:2026-06-19 04:00 → 新闻日 "20260618"
+
+---
+
+## 15. 常见问题
+
+### Q: 如何新增新闻源?
+
+编辑 `configs/sources.yaml`,添加源配置:
+
+```yaml
+- id: "new_source"
+ name: "New Source Name"
+ enabled: true
+ homepage: "https://example.com/finance/"
+ article_url_pattern: "/news/[^/]+/"
+ js_render: false
+ max_articles_per_run: 30
+```
+
+### Q: 翻译质量不好怎么办?
+
+1. 调整 `configs/system.yaml` 中 `llm.temperature`(降低更保守)
+2. 编辑 `prompts/translation_and_extraction.md` 优化 Prompt
+3. 切换 Provider:`llm.provider: "qwen"`
+
+### Q: Qdrant 检索太慢?
+
+- 本地文件模式已足够快(17 条 < 0.01s)
+- 数据量 > 10 万条时建议切换到 Docker 模式
+- 设置 `QDRANT_URL=http://your-server:6333`
+
+### Q: 如何查看日志?
+
+```bash
+tail -f logs/sync.log # 同步日志(国内)
+tail -f logs/pipeline.log # 管道日志(国内)
+tail -f logs/crawl.log # 抓取日志(海外)
+tail -f logs/pack.log # 打包日志(海外)
+```
+
+日志自动清理:每周日凌晨 3 点删除 14 天前的 `.log` 文件(`scripts/cleanup_logs.sh`)。
+
+### Q: 数据如何备份?
+
+```bash
+# 备份整个 data 目录
+tar czf intlnews_backup_$(date +%Y%m%d).tar.gz data/
+```
+
+---
+
+## 附录:技术栈
+
+| 组件 | 技术 |
+|------|------|
+| 语言 | Python 3.11 |
+| 包管理 | uv + pyproject.toml |
+| 抓取 | Crawl4AI + Playwright |
+| 正文提取 | trafilatura |
+| 去重 | SimHash + SQLite |
+| LLM | DeepSeek v4-flash(OpenAI SDK) |
+| Embedding | DashScope text-embedding-v3 |
+| 向量库 | Qdrant(本地文件模式) |
+| MCP | FastMCP |
+| CLI | Typer |
+| 配置 | YAML + .env |
+| 数据模型 | Pydantic v2 |
diff --git a/embedding/__init__.py b/embedding/__init__.py
new file mode 100644
index 0000000..2eac7ff
--- /dev/null
+++ b/embedding/__init__.py
@@ -0,0 +1,35 @@
+"""向量生成模块 (M5)。
+
+公共 API:
+ - load_embedding_config / make_embedding_client
+ - compose_text / embed_article / embed_articles
+ - embed_all_events(批量管道)
+ - EmbeddingResult / EmbeddingConfig / EmbeddingError
+"""
+
+from embedding.client import (
+ EmbeddingConfig,
+ embed_batch,
+ load_embedding_config,
+ make_embedding_client,
+)
+from embedding.embedder import compose_text, embed_article, embed_articles
+from embedding.models import EmbeddingError, EmbeddingResult
+from embedding.pipeline import embed_all_events
+
+__all__ = [
+ # 客户端
+ "EmbeddingConfig",
+ "embed_batch",
+ "load_embedding_config",
+ "make_embedding_client",
+ # 嵌入
+ "compose_text",
+ "embed_article",
+ "embed_articles",
+ # 批量管道
+ "embed_all_events",
+ # 模型
+ "EmbeddingError",
+ "EmbeddingResult",
+]
diff --git a/embedding/client.py b/embedding/client.py
new file mode 100644
index 0000000..1b70225
--- /dev/null
+++ b/embedding/client.py
@@ -0,0 +1,198 @@
+"""DashScope Embedding 客户端。
+
+通过 OpenAI 兼容接口调用阿里百炼 text-embedding-v3:
+ base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
+ model: text-embedding-v3(1024 维)
+
+配置来源:
+ - .env → DASHSCOPE_API_KEY / QWEN_BASE_URL(OpenAI 兼容端点)
+ - configs/system.yaml → embedding 段(model / dimension / batch_size / timeout)
+"""
+
+import logging
+import os
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+from openai import OpenAI
+
+from embedding.models import EmbeddingError
+
+logger = logging.getLogger(__name__)
+
+# 默认值
+DASHSCOPE_DEFAULT_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+DASHSCOPE_DEFAULT_MODEL = "text-embedding-v3"
+DASHSCOPE_DEFAULT_DIM = 1024
+DASHSCOPE_BATCH_LIMIT = 10 # 百炼实测单批上限
+
+# 重试
+DEFAULT_MAX_ATTEMPTS = 3
+RETRY_BASE_WAIT_SEC = 1.0
+RETRY_MAX_WAIT_SEC = 8.0
+
+
+def _load_embedding_config() -> dict:
+ """从 system.yaml 加载 embedding 段配置。"""
+ 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 raw.get("embedding", {})
+ except Exception:
+ logger.warning("加载 embedding 配置失败")
+ return {}
+
+
+@dataclass
+class EmbeddingConfig:
+ """Embedding 调用配置。"""
+
+ provider: str = "dashscope"
+ model: str = DASHSCOPE_DEFAULT_MODEL
+ api_key: str = ""
+ base_url: str = DASHSCOPE_DEFAULT_BASE
+ dimension: int = DASHSCOPE_DEFAULT_DIM
+ batch_size: int = DASHSCOPE_BATCH_LIMIT
+ timeout_sec: float = 30.0
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS
+
+ def __post_init__(self) -> None:
+ if not self.api_key:
+ raise EmbeddingError("DASHSCOPE_API_KEY 未配置,请检查 .env")
+
+
+def load_embedding_config(
+ *,
+ model: str | None = None,
+) -> EmbeddingConfig:
+ """根据配置构造 EmbeddingConfig。
+
+ 优先级: system.yaml > .env 默认值 > 硬编码默认值
+ """
+ sys_cfg = _load_embedding_config()
+
+ # API key: 从环境变量读取
+ api_key = os.environ.get("DASHSCOPE_API_KEY", "")
+ if not api_key:
+ api_key = os.environ.get("QWEN_API_KEY", "")
+
+ # Base URL: 优先用 QWEN_BASE_URL(OpenAI 兼容),
+ # DASHSCOPE_BASE_URL 通常是旧版非兼容端点,不作为默认
+ base_url = (
+ os.environ.get("QWEN_BASE_URL")
+ or os.environ.get("DASHSCOPE_EMBEDDING_BASE_URL")
+ or DASHSCOPE_DEFAULT_BASE
+ )
+
+ m = model or sys_cfg.get("dashscope_model", DASHSCOPE_DEFAULT_MODEL)
+ dimension = int(sys_cfg.get("dimension", DASHSCOPE_DEFAULT_DIM))
+ batch_size = min(int(sys_cfg.get("batch_size", DASHSCOPE_BATCH_LIMIT)), DASHSCOPE_BATCH_LIMIT)
+ timeout = float(sys_cfg.get("timeout_sec", 30.0))
+
+ if not api_key:
+ raise EmbeddingError("DASHSCOPE_API_KEY 未配置,请检查 .env")
+
+ return EmbeddingConfig(
+ provider="dashscope",
+ model=m,
+ api_key=api_key,
+ base_url=base_url,
+ dimension=dimension,
+ batch_size=batch_size,
+ timeout_sec=timeout,
+ )
+
+
+def make_embedding_client(config: EmbeddingConfig) -> OpenAI:
+ """构造同步 OpenAI 客户端(指向 DashScope 兼容端点)。"""
+ logger.info(
+ "初始化 Embedding 客户端: provider=%s model=%s base_url=%s dim=%d",
+ config.provider, config.model, config.base_url, config.dimension,
+ )
+ return OpenAI(
+ api_key=config.api_key,
+ base_url=config.base_url,
+ timeout=config.timeout_sec,
+ )
+
+
+def _chunked(items: list[str], size: int) -> list[list[str]]:
+ """把列表按 size 分块。"""
+ return [items[i : i + size] for i in range(0, len(items), size)]
+
+
+def embed_batch(
+ client: OpenAI,
+ config: EmbeddingConfig,
+ texts: list[str],
+) -> list[list[float]]:
+ """批量嵌入,自动分块+重试。
+
+ Args:
+ client: OpenAI 客户端
+ config: Embedding 配置
+ texts: 待嵌入文本列表
+
+ Returns:
+ 与 texts 等长的向量列表,每个为 1024 维 float 列表
+ """
+ if not texts:
+ return []
+
+ all_results: list[list[float]] = []
+ chunks = _chunked(texts, config.batch_size)
+
+ for chunk_idx, chunk in enumerate(chunks):
+ result = _call_with_retry(client, config, chunk, chunk_idx, len(chunks))
+ all_results.extend(result)
+
+ return all_results
+
+
+def _call_with_retry(
+ client: OpenAI,
+ config: EmbeddingConfig,
+ batch: list[str],
+ chunk_idx: int,
+ total_chunks: int,
+) -> list[list[float]]:
+ """单批嵌入调用,带指数退避重试。"""
+ last_err: Exception | None = None
+
+ for attempt in range(1, config.max_attempts + 1):
+ try:
+ resp = client.embeddings.create(model=config.model, input=batch)
+ vectors = [d.embedding for d in resp.data]
+
+ # 维度校验
+ if vectors and len(vectors[0]) != config.dimension:
+ logger.warning(
+ "实际维度 %d 与预期 %d 不一致",
+ len(vectors[0]), config.dimension,
+ )
+
+ logger.debug(
+ "Embedding chunk %d/%d 完成(%d 条,attempt %d)",
+ chunk_idx + 1, total_chunks, len(batch), attempt,
+ )
+ return vectors
+
+ except Exception as e:
+ last_err = e
+ logger.warning(
+ "DashScope embed 失败 chunk %d/%d 尝试 %d/%d: %s: %s",
+ chunk_idx + 1, total_chunks, attempt, config.max_attempts,
+ type(e).__name__, e,
+ )
+ if attempt < config.max_attempts:
+ wait = min(RETRY_BASE_WAIT_SEC * (2 ** (attempt - 1)), RETRY_MAX_WAIT_SEC)
+ time.sleep(wait)
+
+ raise EmbeddingError(
+ f"DashScope embed 放弃({config.max_attempts} 次): {last_err}",
+ attempts=config.max_attempts,
+ )
diff --git a/embedding/embedder.py b/embedding/embedder.py
new file mode 100644
index 0000000..bd4f97d
--- /dev/null
+++ b/embedding/embedder.py
@@ -0,0 +1,141 @@
+"""文本组装与向量生成。
+
+核心逻辑:
+ 1. compose_text: 从 EnTranslatedArticle 拼接中文文本供嵌入
+ 2. embed_article: 单篇文章嵌入
+ 3. embed_articles: 批量嵌入(自动分块)
+"""
+
+import logging
+
+from openai import OpenAI
+
+from embedding.client import EmbeddingConfig, embed_batch
+from embedding.models import EmbeddingResult
+from llm.models import EnTranslatedArticle
+
+logger = logging.getLogger(__name__)
+
+# 嵌入文本最大字符数(DashScope text-embedding-v3 支持 8192 token,保守取 4000 字符)
+MAX_EMBED_CHARS = 4000
+
+
+def compose_text(article: EnTranslatedArticle, max_chars: int = MAX_EMBED_CHARS) -> str:
+ """把 EnTranslatedArticle 组装成单段嵌入文本。
+
+ 拼接策略:
+ - 中文标题(最高信号)
+ - 事件摘要(语义浓缩)
+ - 中文正文(截断)
+
+ Args:
+ article: M4 输出的双语文章
+ max_chars: 整段最大字符数
+
+ Returns:
+ 拼接后的中文嵌入文本
+ """
+ parts: list[str] = []
+
+ # 1. 标题(中文)
+ if article.title_zh:
+ parts.append(f"标题: {article.title_zh}")
+
+ # 2. 事件标签(语义浓缩)
+ for ev in article.events:
+ event_parts = [
+ f"[{ev.sentiment.value}]",
+ f"{ev.event_type}",
+ f"重要度{ev.importance}",
+ ]
+ if ev.stock_codes:
+ event_parts.append("代码:" + ",".join(ev.stock_codes[:5]))
+ if ev.summary_zh:
+ event_parts.append(ev.summary_zh)
+ parts.append("事件: " + " ".join(event_parts))
+
+ # 3. 中文正文
+ if article.content_zh:
+ body = article.content_zh
+ parts.append(f"正文: {body}")
+
+ text = "\n".join(parts)
+
+ # 截断保护
+ if len(text) > max_chars:
+ logger.debug("嵌入文本超长 %d → %d", len(text), max_chars)
+ text = text[:max_chars]
+
+ return text
+
+
+def embed_article(
+ client: OpenAI,
+ config: EmbeddingConfig,
+ article: EnTranslatedArticle,
+) -> EmbeddingResult:
+ """单篇文章嵌入。
+
+ Args:
+ client: OpenAI 客户端
+ config: Embedding 配置
+ article: M4 输出的双语文章
+
+ Returns:
+ EmbeddingResult 含向量 + 元信息
+ """
+ text = compose_text(article)
+
+ vectors = embed_batch(client, config, [text])
+ if not vectors:
+ raise ValueError(f"嵌入返回空结果: {article.url_hash}")
+
+ return EmbeddingResult(
+ url_hash=article.url_hash,
+ source_id=article.source_id,
+ vector=vectors[0],
+ dimension=len(vectors[0]),
+ embedded_text=text,
+ provider=config.provider,
+ model=config.model,
+ )
+
+
+def embed_articles(
+ client: OpenAI,
+ config: EmbeddingConfig,
+ articles: list[EnTranslatedArticle],
+) -> list[EmbeddingResult]:
+ """批量嵌入多篇文章(自动分块+重试)。
+
+ Args:
+ client: OpenAI 客户端
+ config: Embedding 配置
+ articles: M4 输出的双语文章列表
+
+ Returns:
+ EmbeddingResult 列表
+ """
+ if not articles:
+ return []
+
+ # 组装所有嵌入文本
+ texts = [compose_text(a) for a in articles]
+
+ # 批量嵌入
+ vectors = embed_batch(client, config, texts)
+
+ # 组装结果
+ results: list[EmbeddingResult] = []
+ for article, vec in zip(articles, vectors):
+ results.append(EmbeddingResult(
+ url_hash=article.url_hash,
+ source_id=article.source_id,
+ vector=vec,
+ dimension=len(vec),
+ embedded_text=compose_text(article),
+ provider=config.provider,
+ model=config.model,
+ ))
+
+ return results
diff --git a/embedding/models.py b/embedding/models.py
new file mode 100644
index 0000000..ce75901
--- /dev/null
+++ b/embedding/models.py
@@ -0,0 +1,34 @@
+"""Embedding 向量生成数据模型 (M5)。"""
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class EmbeddingResult(BaseModel):
+ """单篇文章的嵌入向量结果,M5 最终落盘格式。"""
+
+ # 来源标识
+ url_hash: str
+ source_id: str
+
+ # 嵌入向量(1024 维 float 列表)
+ vector: list[float] = Field(..., description="1024 维浮点向量")
+ dimension: int = 1024
+
+ # 嵌入文本(用于检索时调试/可视化)
+ embedded_text: str = Field(default="", description="拼接后送入 embedder 的文本")
+
+ # 调用元信息
+ provider: str = "dashscope"
+ model: str = ""
+ embedded_at: datetime = Field(default_factory=datetime.now)
+
+
+class EmbeddingError(Exception):
+ """Embedding 调用失败。"""
+
+ def __init__(self, reason: str, *, attempts: int = 0) -> None:
+ super().__init__(reason)
+ self.reason = reason
+ self.attempts = attempts
diff --git a/embedding/pipeline.py b/embedding/pipeline.py
new file mode 100644
index 0000000..b944a6b
--- /dev/null
+++ b/embedding/pipeline.py
@@ -0,0 +1,179 @@
+"""批量向量生成管道。
+
+输入: data/events/{YYYYMMDD}/{url_hash}.json(M4 翻译+事件输出)
+输出: data/embeddings/{YYYYMMDD}/{url_hash}.json
+"""
+
+import json
+import logging
+from datetime import datetime
+from pathlib import Path
+
+from crawler.utils import get_news_day
+from embedding.client import (
+ EmbeddingConfig,
+ load_embedding_config,
+ make_embedding_client,
+)
+from embedding.embedder import embed_articles
+from embedding.models import EmbeddingError
+from llm.models import EnTranslatedArticle
+
+logger = logging.getLogger(__name__)
+
+
+def _load_event_articles(date_str: str) -> list[EnTranslatedArticle]:
+ """加载指定日期的翻译+事件文章。
+
+ Args:
+ date_str: 日期 YYYYMMDD
+
+ Returns:
+ EnTranslatedArticle 列表
+ """
+ base_dir = Path(f"data/events/{date_str}")
+ if not base_dir.exists():
+ return []
+
+ articles: list[EnTranslatedArticle] = []
+ for json_file in sorted(base_dir.glob("*.json")):
+ if json_file.name == "index.json":
+ continue
+ try:
+ data = json.loads(json_file.read_text(encoding="utf-8"))
+ articles.append(EnTranslatedArticle(**data))
+ except (json.JSONDecodeError, Exception) as e:
+ logger.warning("解析事件文章失败 %s: %s", json_file, e)
+
+ return articles
+
+
+def embed_all_events(
+ date_str: str | None = None,
+ *,
+ model: str | None = None,
+) -> dict:
+ """对所有 M4 输出的文章执行向量化。
+
+ Args:
+ date_str: 日期 YYYYMMDD,默认当前新闻日
+ model: Embedding 模型名,默认从 system.yaml 读取
+
+ Returns:
+ 统计 dict
+ """
+ if date_str is None:
+ date_str = get_news_day()
+
+ logger.info("══════ 开始向量生成,日期: %s ══════", date_str)
+
+ # 加载文章
+ articles = _load_event_articles(date_str)
+ if not articles:
+ logger.warning("事件目录无文章: data/events/%s/", date_str)
+ return {"date": date_str, "total": 0, "success": 0, "failed": 0, "elapsed_sec": 0}
+
+ # 初始化 Embedding 客户端
+ config = load_embedding_config(model=model)
+ client = make_embedding_client(config)
+
+ # 输出目录
+ out_dir = Path(f"data/embeddings/{date_str}")
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ # 增量:跳过已向量化的文章
+ new_articles = []
+ skipped = 0
+ for a in articles:
+ if (out_dir / f"{a.url_hash}.json").exists():
+ skipped += 1
+ else:
+ new_articles.append(a)
+ if skipped > 0:
+ logger.info("增量跳过 %d 篇已向量化,剩余 %d 篇待处理", skipped, len(new_articles))
+ articles = new_articles
+
+ start_time = datetime.now()
+ success = 0
+ failed = 0
+
+ # 批量嵌入(按 batch_size 分块,每批输出进度)
+ batch_size = config.batch_size
+ total = len(articles)
+ logger.info("开始向量化 %d 篇文章(batch_size=%d, model=%s)",
+ total, batch_size, config.model)
+
+ for batch_start in range(0, total, batch_size):
+ batch_end = min(batch_start + batch_size, total)
+ batch = articles[batch_start:batch_end]
+
+ try:
+ results = embed_articles(client, config, batch)
+ for result in results:
+ out_file = out_dir / f"{result.url_hash}.json"
+ out_file.write_text(
+ result.model_dump_json(indent=2, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ success += 1
+
+ logger.info(" [%d/%d] ✅ %d 篇 → %d 维向量",
+ batch_end, total, len(results), config.dimension)
+
+ except EmbeddingError as e:
+ failed += len(batch)
+ logger.error("批量嵌入失败 [%d-%d]: %s", batch_start, batch_end, e.reason)
+ except Exception as e:
+ failed += len(batch)
+ logger.exception("批量嵌入异常 [%d-%d]: %s", batch_start, batch_end, e)
+
+ elapsed = (datetime.now() - start_time).total_seconds()
+
+ # 写入索引
+ _write_embedding_index(date_str, success, failed, elapsed, config)
+
+ logger.info(
+ "══════ 向量生成完成: 成功 %d / 失败 %d / 总计 %d,耗时 %.1f 秒 ══════",
+ success, failed, len(articles), elapsed,
+ )
+
+ return {
+ "date": date_str,
+ "total": len(articles),
+ "success": success,
+ "failed": failed,
+ "elapsed_sec": elapsed,
+ "provider": config.provider,
+ "model": config.model,
+ "dimension": config.dimension,
+ }
+
+
+def _write_embedding_index(
+ date_str: str,
+ success: int,
+ failed: int,
+ elapsed_sec: float,
+ config: EmbeddingConfig,
+) -> None:
+ """写入向量索引文件。"""
+ out_dir = Path(f"data/embeddings/{date_str}")
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ index_data = {
+ "date": date_str,
+ "success": success,
+ "failed": failed,
+ "elapsed_sec": round(elapsed_sec, 1),
+ "provider": config.provider,
+ "model": config.model,
+ "dimension": config.dimension,
+ "generated_at": datetime.now().isoformat(),
+ }
+
+ index_path = out_dir / "index.json"
+ index_path.write_text(
+ json.dumps(index_data, indent=2, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ logger.info("向量索引已写入: %s", index_path)
diff --git a/english-news-plan.md b/english-news-plan.md
new file mode 100644
index 0000000..750fe5b
--- /dev/null
+++ b/english-news-plan.md
@@ -0,0 +1,477 @@
+# English Financial News — 项目开发计划
+
+> 国际财经新闻抓取与深度研究平台
+>
+> 对标 `news/` (A 股 Deep Research),复用其架构模式,聚焦英文财经源。
+
+---
+
+## 一、项目定位
+
+构建面向国际财经新闻的私有化 Deep Research 平台。
+
+核心能力:
+- 英文财经新闻抓取(Crawl4AI)
+- 新闻联播数据接入(api.doorcome.cn)
+- 全文英译中(LLM)
+- 投资事件抽取(LLM)
+- 双语向量知识库(Qdrant)
+- MCP 服务 + Cherry Studio / Claude Code Agent 深度研究
+- 每日 AI 摘要日报(含新闻联播投资相关解读)
+
+本项目不是:
+- 交易系统
+- 预测系统
+- 投资顾问
+
+---
+
+## 二、部署拓扑
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Overseas Server (国外) │
+│ M1 Crawl4AI 抓取 → data/raw/{source}/{YYYYMMDD}/ │
+│ ↓ │
+│ rsync 增量推送 (定时) │
+└──────────────────────────┬───────────────────────────────────┘
+ │ SSH / rsync
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ Domestic Server (国内) │
+│ M2 正文提取 → M3 去重 → M4 翻译+事件抽取(LLM) │
+│ → M5 向量生成 → M6 Qdrant 入库 │
+│ → M7 定时调度 → M8 MCP 服务 │
+│ → 日报生成 (AI 摘要 + 重要事件 + 新闻联播投资解读) │
+│ │
+│ ┌── 新闻联播数据源 (独立 API) ──┐ │
+│ │ GET api.doorcome.cn/api/xwlbFine/ │
+│ │ → LLM 筛选投资相关 → 重要性评分 → AI 解读 │
+│ └──────────────────────────────┘ │
+└──────────────────────────────────────────────────────────────┘
+```
+
+同步机制:
+- 海外服务器每天定时 `rsync -avz` 推送 `data/raw/` 到国内
+- 国内定时任务先拉取同步,再执行 M2→M8 管道
+- rsync 天然增量(只传新文件),带宽高效
+
+---
+
+## 三、Milestone 分解
+
+### M0 — 项目骨架
+
+- `pyproject.toml`(Python 3.11 + uv)
+- `.env.example`
+- `configs/sources.yaml`(英文财经源配置)
+- `CLAUDE.md`
+- 目录结构初始化
+- CLI 入口:`en-news`
+
+### M1 — 新闻抓取(海外服务器)
+
+**技术选型**:Crawl4AI(异步 + Playwright JS 渲染),与 A 股项目一致。
+
+**英文财经源**(初始 12 个):
+
+| 源 ID | 名称 | 类型 |
+|-------|------|------|
+| reuters | Reuters | 综合财经 |
+| cnbc | CNBC | 市场新闻 |
+| marketwatch | MarketWatch | 市场数据 |
+| ft | Financial Times | 财经深度 |
+| yahoo_finance | Yahoo Finance | 综合 |
+| investing | Investing.com | 全球市场 |
+| seekingalpha | Seeking Alpha | 投资分析 |
+| barrons | Barrons | 市场评论 |
+| wsj | WSJ | 综合财经 |
+| economist | The Economist | 经济分析 |
+| forexlive | ForexLive | 外汇新闻 |
+| zerohedge | ZeroHedge | 另类财经 |
+
+每个源配置字段:
+```yaml
+- id: "reuters"
+ name: "Reuters"
+ enabled: true
+ homepage: "https://www.reuters.com/business/"
+ article_url_pattern: "/[^/]+/[^/]+/"
+ js_render: true
+ max_articles_per_run: 30
+```
+
+**产物**:`data/raw/{source_id}/{YYYYMMDD}/{url_hash}.html` + `index.jsonl`
+
+**同步守护进程**:
+```bash
+# 海外 crontab:每天 05:00 UTC 推送
+0 5 * * * rsync -avz --ignore-existing /app/data/raw/ user@domestic:/app/data/raw/
+```
+
+### M2 — 正文提取(国内服务器)
+
+**输入**:`data/raw/{source_id}/{date}/`(rsync 同步后)
+
+**提取方案**:
+- 优先使用 Crawl4AI 输出的 Markdown(`{url_hash}.md`)
+- 对未生成 Markdown 的源,用 `trafilatura`(英文正文提取,类 GNE 但针对英文优化)
+- 输出标准化 `Article` 模型(`title` / `content` / `publish_time` / `author` / `source_id`)
+
+**产物**:`data/processed/{source_id}/{YYYYMMDD}/{url_hash}.json`
+
+### M3 — 去重
+
+与 A 股项目相同的三层去重:
+1. URL Hash(精确)
+2. 内容 Hash(SHA256 前 64 字符)
+3. SimHash 模糊(汉明距离 ≤ 3,30 天窗口)
+
+**产物**:`data/deduped/{YYYYMMDD}/uniques/{url_hash}.json`
+
+### M4 — 翻译 + 投资事件抽取(LLM)
+
+**单次 LLM 调用完成两件事**(节省 token):
+
+Prompt 设计:
+```
+输入:英文财经新闻全文
+输出 JSON:
+{
+ "translation_zh": "中文全文翻译",
+ "events": [
+ {
+ "event_type": "并购/财报/政策/行业/...",
+ "stock_codes": ["AAPL", "TSLA"],
+ "sentiment": "positive/negative/neutral",
+ "importance": 1-5,
+ "summary_zh": "事件中文摘要"
+ }
+ ]
+}
+```
+
+**LLM Provider**:DeepSeek(默认)/ Qwen 备选
+
+**产物**:`data/events/{YYYYMMDD}/{url_hash}.json`
+```json
+{
+ "title": "Apple Reports Record Q2 Earnings",
+ "title_zh": "苹果公布创纪录第二季度财报",
+ "content_en": "...",
+ "content_zh": "...",
+ "events": [...]
+}
+```
+
+### M5 — 向量生成
+
+- Embedding Provider:DashScope `text-embedding-v3`(1024 维)
+- 对中文翻译内容向量化(`content_zh` + `title_zh` + 事件摘要拼接)
+- 也可选择英文原文向量化,支持双语检索
+
+**产物**:`data/embeddings/{YYYYMMDD}/{url_hash}.json`
+
+### M6 — Qdrant 入库与检索
+
+与 A 股项目相同:
+- 本地文件模式(默认)或 Docker Server 模式
+- Collection:`en_finance_news`
+- Payload:`source_id` / `title` / `title_zh` / `url` / `publish_time` / `events` / `sentiment` / `importance`
+- Vector:从 M5 产物加载
+
+```python
+uv run en-news search "Fed interest rate decision"
+```
+
+### M7 — 新闻联播数据源(独立模块)
+
+**数据获取**:
+
+```
+GET https://api.doorcome.cn/api/xwlbFine/?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD
+```
+
+**响应结构**:
+```json
+{
+ "status": "success",
+ "data": {
+ "news": [
+ {
+ "news_days": "2026-06-20",
+ "daily_sub_id": 1,
+ "news_title": "牢记总书记嘱托 各地在文脉赓序中推动城市高质量发展",
+ "news_improve": "历史文化是城市的灵魂...(全文约3000字)"
+ }
+ ]
+ }
+}
+```
+
+每天约 12-15 条新闻,每条含完整文字稿。
+
+**处理流程**:
+
+1. **获取** — 日报生成前拉取昨日新闻联播数据。例如 6/21 早上执行日报 → 拉取 6/20 的新闻联播(前一日 19:00 播出,次日凌晨 API 已就绪)
+2. **投资相关性筛选(LLM)** — 用 DeepSeek 逐条判断是否与投资相关
+ - 经济数据、产业政策、贸易谈判 → **高相关**(importance 4-5)
+ - 科技创新、区域发展、基建项目 → **中相关**(importance 2-3)
+ - 文化、民生、体育、天气 → **非相关**(跳过)
+3. **AI 解读** — 对投资相关条目生成 2-3 句投资视角解读:
+ - 对哪些行业/板块有影响
+ - 政策信号含义
+ - 市场可能的反应
+4. **纳入日报** — 单独一节展示,区别于英文财经新闻
+
+**产物**:`data/xwlb/{YYYYMMDD}/index.jsonl`
+```json
+{
+ "news_days": "2026-06-20",
+ "daily_sub_id": 5,
+ "news_title": "中欧班列统一品牌十周年",
+ "is_investment_related": true,
+ "importance": 4,
+ "category": "贸易/物流",
+ "ai_interpretation": "中欧班列十年数据将直接反映一带一路贸易活跃度,利好物流、港口、跨境贸易板块..."
+}
+```
+
+### M8 — 定时调度
+
+- 国内 crontab 或 APScheduler 守护进程
+- 日期规则:T 日早上生成的日报覆盖 T-1 日数据(新闻 + 新闻联播)
+- 流程:
+ ```
+ 06:00 等待海外 rsync 完成(检查哨兵文件)
+ 06:30 M2→M3→M4→M5→M6 全链路(处理 T-1 日英文新闻)
+ 07:00 拉取 T-1 日新闻联播 → LLM 筛选解读 → 日报生成
+ 12:00 增量(只处理当日新增)
+ 18:00 增量
+ 22:00 增量 + 日报
+ ```
+
+### M9 — MCP 服务
+
+暴露 5 个 MCP 工具给 Cherry Studio / Claude Code:
+
+| 工具 | 功能 |
+|------|------|
+| `search_en_news` | 通用语义检索(中文查询 → 中文翻译向量匹配) |
+| `search_by_company` | 按公司名/股票代码检索 |
+| `search_by_sentiment` | 按情绪检索+统计 |
+| `search_by_event_type` | 按事件类型检索 |
+| `get_daily_report` | 获取最新日报 |
+
+---
+
+## 四、数据模型
+
+```python
+class EnArticle(BaseModel):
+ source_id: str
+ url: str
+ url_hash: str
+ title: str # 英文原标题
+ title_zh: str # 中文翻译标题
+ content_en: str # 英文原文
+ content_zh: str # 中文全文翻译
+ author: str
+ publish_time: datetime
+ source_name: str
+ word_count: int
+ word_count_zh: int
+
+class EnExtractedEvent(BaseModel):
+ article_url_hash: str
+ event_type: str # 并购/财报/政策/行业/市场/...
+ stock_codes: list[str] # 涉及美股代码
+ sentiment: str # positive/negative/neutral
+ importance: int # 1-5
+ summary_zh: str # 中文摘要
+ summary_en: str # 英文摘要
+
+class XwlbItem(BaseModel):
+ """新闻联播条目(经 LLM 筛选和解读)。"""
+ news_days: str # 播出日期 "2026-06-20"
+ daily_sub_id: int # 当日序号 (1-15)
+ news_title: str # 标题
+ news_improve: str # 完整文字稿
+ is_investment_related: bool # 是否投资相关
+ importance: int # 投资重要性 1-5 (非投资为 0)
+ category: str # 投资类别 (贸易/产业政策/科技/基建/金融/...)
+ ai_interpretation: str # AI 投资解读 2-3 句
+```
+
+---
+
+## 五、日报设计
+
+命令:`uv run en-news pipeline --once --report`
+
+执行顺序:新闻联播拉取 → LLM 筛选解读 → 日报 HTML 生成
+
+六段式结构:
+
+1. **AI 摘要**(24h 国际财经 + 昨日新闻联播投资相关,≤500 字,最后一条不截断)
+
+2. **重要事件:国际新闻**(24h 英文源,importance ≥ 4 逐级回退,最多 20 篇)
+
+3. **📺 新闻联播投资解读**(昨日播出,投资相关条目 + AI 解读)
+ - 展示格式:标题 | 投资相关性类别 | 重要度 | AI 解读(2-3 句)
+ - 非投资相关条目不显示
+ - 示例:
+ ```
+ 📺 中欧班列统一品牌十周年 [贸易/物流] ⭐⭐⭐⭐
+ AI解读: 中欧班列十年数据将直接反映一带一路贸易活跃度。
+ 关注物流、港口、跨境贸易板块。政策利好中国外运、中远海控等。
+ ```
+
+4. **市场情绪分布**(利好/利空/中性,24h 国际新闻 + 新闻联播合并统计)
+
+5. **数据总览**(M1→M6 管道统计 + 各源抓取量 6 列网格 + 重要度分布 + 事件类型分布 + 新闻联播条目数)
+
+6. **完整新闻联播条目列表**(当天的全部条目,标题+简要,含非投资类,供快速浏览)
+
+---
+
+## 六、配置规范
+
+### configs/sources.yaml
+
+```yaml
+settings:
+ concurrency: 5
+ user_agent: "Mozilla/5.0 ..."
+ output_root: "data/raw"
+
+sources:
+ - id: "reuters"
+ name: "Reuters"
+ enabled: true
+ homepage: "https://www.reuters.com/business/"
+ article_url_pattern: "/[^/]+/[^/]+/"
+ js_render: false
+ max_articles_per_run: 30
+```
+
+### .env 关键配置
+
+```bash
+# LLM
+LLM_PROVIDER=deepseek
+LLM_MODEL=deepseek-v4-flash
+DEEPSEEK_API_KEY=sk-xxx
+
+# Embedding
+EMBEDDING_PROVIDER=dashscope
+DASHSCOPE_API_KEY=sk-xxx
+
+# 新闻联播
+XWLB_API_BASE=https://api.doorcome.cn
+
+# Schedule
+SCHEDULE_TIMES=06:30,12:00,18:00,22:00
+SYNC_WAIT_SEC=300
+
+# Qdrant
+QDRANT_COLLECTION=en_finance_news
+
+# Overseas → Domestic
+SYNC_HOST=user@domestic-server
+SYNC_PORT=22
+SYNC_SENTINEL=/tmp/en_news_sync_done
+```
+
+---
+
+## 七、目录结构
+
+```
+english-news/
+├── crawler/ # M1 新闻抓取(Crawl4AI, 部署海外)
+├── extractor/ # M2 英文正文提取(trafilatura)
+├── dedup/ # M3 三层去重
+├── translator/ # M4a 全文翻译(LLM)
+├── llm/ # M4b 投资事件抽取
+├── embedding/ # M5 向量生成
+├── vectorstore/ # M6 Qdrant 客户端
+├── xwlb/ # M7 新闻联播数据源
+├── scheduler/ # M8 定时任务
+├── mcp_server/ # M9 MCP 服务
+├── app/ # CLI 入口(en-news)
+├── configs/ # sources.yaml / system config
+├── prompts/ # LLM Prompt 模板
+├── scripts/ # 部署 & 运维脚本
+│ ├── sync_daemon.sh # 海外 rsync 推送脚本
+│ └── wait_for_sync.py # 国内等待同步完成
+├── tests/ # pytest
+├── docs/ # 设计文档
+├── data/ # 数据目录(git ignored)
+│ ├── raw/ # M1 产物(海外与国内共享)
+│ ├── processed/ # M2 产物
+│ ├── deduped/ # M3 产物
+│ ├── events/ # M4 产物
+│ ├── embeddings/ # M5 产物
+│ ├── xwlb/ # 新闻联播数据(经 LLM 筛选与解读)
+│ ├── qdrant_storage/ # M6 Qdrant 本地存储
+│ └── reports/ # 日报 HTML
+├── logs/ # 运行日志
+├── pyproject.toml
+├── CLAUDE.md
+└── README.md
+```
+
+---
+
+## 八、技术选型对比
+
+| 模块 | A 股项目 | 英文财经项目 | 差异 |
+|------|---------|-------------|------|
+| 抓取 | Crawl4AI | Crawl4AI | 一致 |
+| 正文提取 | GNE | trafilatura | 英文优化 |
+| 翻译 | — | LLM (DeepSeek) | **新增** |
+| 去重 | 三层 | 三层 | 一致 |
+| LLM 事件 | DeepSeek/Qwen | DeepSeek/Qwen | 一致 |
+| Embedding | DashScope | DashScope | 一致 |
+| 向量库 | Qdrant | Qdrant | 一致 |
+| 新闻联播 | — | api.doorcome.cn + LLM 筛选 | **新增** |
+| 调度 | APScheduler | APScheduler | 一致 |
+| MCP | FastMCP | FastMCP | 一致 |
+| CLI | a-share | en-news | 命名区分 |
+
+---
+
+## 九、开发优先级(建议顺序)
+
+| 序号 | Milestone | 预估工期 | 依赖 |
+|------|-----------|---------|------|
+| 1 | M0 项目骨架 | 0.5 天 | — |
+| 2 | M1 抓取(海外) | 2 天 | M0 |
+| 3 | rsync 同步机制 | 0.5 天 | M1 |
+| 4 | M2 正文提取 | 1 天 | 同步就绪 |
+| 5 | M3 去重 | 1 天 | M2 |
+| 6 | M4 翻译+事件抽取 | 1.5 天 | M3 |
+| 7 | M5 向量 + M6 Qdrant | 1 天 | M4 |
+| 8 | M7 新闻联播数据源 | 1 天 | — (独立模块) |
+| 9 | M8 调度 + 日报 | 1.5 天 | M6, M7 |
+| 10 | M9 MCP 服务 | 1 天 | M6 |
+| **总计** | | **~11 天** | |
+
+---
+
+## 十、关键风险
+
+| 风险 | 缓解措施 |
+|------|---------|
+| 英文源 JS 渲染复杂 | Crawl4AI 已有 Playwright 支持,优先使用轻量级源 |
+| LLM 翻译质量不稳定 | System prompt 约束翻译风格,保留原文备查 |
+| rsync 网络不稳定 | 添加重试机制 + 哨兵文件完整性检查 |
+| 海外 IP 被封 | 轮换 User-Agent,控制请求频率(≥ 2s/req) |
+| DeepSeek API 限流 | 并发控制 + Qwen 备选 provider |
+
+---
+
+> 本计划文件:`~/Downloads/cc-projects/english-news-plan.md`
+>
+> 最后更新:2026-06-20
diff --git a/extractor/__init__.py b/extractor/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/extractor/extractor.py b/extractor/extractor.py
new file mode 100644
index 0000000..bfb4675
--- /dev/null
+++ b/extractor/extractor.py
@@ -0,0 +1,258 @@
+"""英文正文提取引擎
+
+策略:
+1. 优先 trafilatura 从原始 HTML 提取(除导航/广告/页脚)
+2. 回退 Crawl4AI Markdown(如果 HTML 不可用)
+3. 元数据提取(发布时间、作者)
+所有业务参数从 configs/system.yaml 的 extractor 节读取。
+"""
+
+import logging
+from pathlib import Path
+
+import trafilatura
+import yaml
+from htmldate import find_date
+
+from extractor.models import ProcessedArticle
+
+logger = logging.getLogger(__name__)
+
+
+def _load_extractor_config() -> dict:
+ """从 system.yaml 读取 extractor 配置节"""
+ 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 raw.get("extractor", {})
+ except Exception:
+ logger.warning("读取 system.yaml 失败,使用默认值")
+ return {}
+
+_cfg = _load_extractor_config()
+
+# 最小正文字数阈值
+MIN_CONTENT_WORDS = int(_cfg.get("min_content_words", 50))
+
+
+def extract_article(
+ source_id: str,
+ source_name: str,
+ url: str,
+ url_hash: str,
+ html_path: str,
+ md_path: str,
+ crawl_publish_time: str = "",
+) -> ProcessedArticle:
+ """提取单篇文章的正文
+
+ Args:
+ source_id: 新闻源 ID
+ source_name: 新闻源名称
+ url: 文章 URL
+ url_hash: URL SHA256 前 16 位
+ html_path: 原始 HTML 文件路径
+ md_path: Crawl4AI 生成的 Markdown 文件路径
+ crawl_publish_time: RSS/Crawler 提取的发布时间(回退用)
+
+ Returns:
+ ProcessedArticle
+ """
+ article = ProcessedArticle(
+ source_id=source_id,
+ source_name=source_name,
+ url=url,
+ url_hash=url_hash,
+ html_path=html_path,
+ md_path=md_path,
+ )
+
+ html_p = Path(html_path) if html_path else None
+ md_p = Path(md_path) if md_path else None
+
+ # ── 策略 1: trafilatura 从 HTML 提取 ──────────────
+ if html_p and html_p.exists():
+ try:
+ html_raw = html_p.read_text(encoding="utf-8")
+ content = trafilatura.extract(
+ html_raw,
+ include_comments=False,
+ include_tables=False,
+ no_fallback=False,
+ favor_precision=True,
+ )
+ if content and _count_words(content) >= MIN_CONTENT_WORDS:
+ article.content = content.strip()
+ article.extractor = "trafilatura"
+ article.word_count = _count_words(article.content)
+
+ # 元数据
+ article.title = _extract_title(html_raw) or ""
+ article.publish_time = (
+ _extract_publish_time(html_raw, url=url)
+ or crawl_publish_time
+ or ""
+ )
+ article.author = _extract_author(html_raw) or ""
+
+ article.status = "success"
+ return article
+ except Exception as e:
+ logger.warning("[%s] trafilatura 提取失败: %s — %s", source_id, url_hash, e)
+
+ # ── 策略 2: Crawl4AI Markdown 回退 ─────────────────
+ if md_p and md_p.exists():
+ try:
+ md_content = md_p.read_text(encoding="utf-8")
+ if _count_words(md_content) >= MIN_CONTENT_WORDS:
+ article.content = _clean_markdown(md_content)
+ article.extractor = "crawl4ai_md"
+ article.word_count = _count_words(article.content)
+
+ # 从 Markdown 中提取元数据(RSS 源会写入)
+ article.title = _extract_from_md(
+ md_content, r"^#\s+(.+)$", article.title
+ )
+ article.publish_time = (
+ _extract_from_md(
+ md_content, r"\*\*发布时间\*\*:?\s*([^\n]+)", ""
+ )
+ or _extract_publish_time("", url=url)
+ or crawl_publish_time
+ or ""
+ )
+
+ article.status = "success"
+ return article
+ except Exception as e:
+ logger.warning("[%s] Markdown 回退失败: %s — %s", source_id, url_hash, e)
+
+ # ── 失败 ─────────────────────────────────────────
+ article.status = "no_content"
+ article.error = "No extractable content"
+ return article
+
+
+# ── 辅助函数 ────────────────────────────────────────
+
+
+def _count_words(text: str) -> int:
+ """英文词数统计"""
+ return len(text.split()) if text else 0
+
+
+def _extract_title(html: str) -> str:
+ """从 HTML 标签提取标题"""
+ import re
+ m = re.search(r"]*>(.*?)", html, re.IGNORECASE | re.DOTALL)
+ if m:
+ title = re.sub(r"<[^>]+>", "", m.group(1))
+ return title.strip()
+ return ""
+
+
+def _extract_publish_time(html: str, url: str = "") -> str:
+ """从 HTML 提取发布时间(含回退策略)。
+
+ 优先级:
+ 1. htmldate 提取(元数据/标签/正文)
+ 2. 等常见标签
+ 3. URL 中的日期(/YYYY/MM/DD/ 或 /YYYYMMDD/)
+ """
+ # 策略 1: htmldate
+ try:
+ result = find_date(html)
+ if result:
+ return result
+ except Exception:
+ pass
+
+ # 策略 2: 常见 meta 标签
+ if html:
+ import re
+ meta_patterns = [
+ r']+property=["\']article:published_time["\'][^>]+content=["\']([^"\']+)',
+ r']+content=["\']([^"\']+)["\'][^>]+property=["\']article:published_time',
+ r']+name=["\']pubdate["\'][^>]+content=["\']([^"\']+)',
+ r']+name=["\']publish_date["\'][^>]+content=["\']([^"\']+)',
+ r']+name=["\']date["\'][^>]+content=["\']([^"\']+)',
+ r'