diff --git a/.env.example b/.env.example
index dbbaaea..94851ca 100644
--- a/.env.example
+++ b/.env.example
@@ -69,3 +69,11 @@ PIPELINE_STEP_TIMEOUT=1800
# ---- cninfo 公告抓取 ----
CNINFO_PDF_BASE=http://static.cninfo.com.cn
+
+# ---- 日报结构化入库 (M10) ----
+NEWS_DB_HOST=127.0.0.1 # 开发走 ssh 隧道: ssh -L 13306:127.0.0.1:13306 pi
+NEWS_DB_PORT=13306
+NEWS_DB_USER=myquant
+NEWS_DB_PASSWORD= # 填真实值,禁止写入源码/文档
+NEWS_DB_NAME=myquant
+REPORT_HISTORY_DIR=data/reports_history
diff --git a/.gitignore b/.gitignore
index f692feb..9d4e713 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,6 @@ qdrant_storage/
# 调试输出
debug/
tmp/
+
+# M10: 历史日报源文件副本(可从 doorcome 重新拉取)
+data/reports_history/
diff --git a/CLAUDE.md b/CLAUDE.md
index 6b4490a..a257767 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -229,7 +229,7 @@ MAX_IMPORTANCE = 5
统一使用:
-logging
+loguru(现有代码全部使用 loguru,禁止混用 stdlib logging)
禁止:
@@ -623,5 +623,40 @@ Claude Code 完成任务时必须输出:
“小步迭代、稳定演进、长期维护”。
+# 二十一、项目现状速查(2026-07 校验)
+
+## 入口与常用命令
+
+- 统一 CLI:`uv run a-share <子命令>`,入口 `a_share_cli/main.py`
+- 子命令:`crawl` / `extract` / `dedup` / `events` / `embed` / `ingest` / `pipeline --once` / `search` / `report` / `status` / `discover`
+- 测试:`uv run pytest`(tests/,asyncio_mode=auto;integration 标记默认跳过)
+- 静态检查:`uv run ruff check .`、`uv run mypy`(pyproject.toml 已配置)
+- 环境:`uv sync`;本地 BGE-M3 需 `uv sync --extra local-embedding`
+
+## 架构(9 个包)
+
+| 包 | 职责 |
+| --- | --- |
+| crawler | Crawl4AI 抓取;js_render=false 走 httpx 静态直连,否则 Playwright;含 cninfo 公告 |
+| extractor | GNE 中文正文提取 |
+| dedup | 新闻去重(SimHash) |
+| llm | DeepSeek/Qwen 投资事件抽取 |
+| embedding | 远程 DashScope / 本地 BGE-M3 |
+| vectorstore | Qdrant;默认本地文件模式 data/qdrant_storage/ |
+| scheduler | APScheduler 调度 + pipeline + 日报 reporter |
+| mcp_server | MCP 工具(供 Cherry Studio) |
+| api | 占位(空包) |
+
+scripts/ 为独立运行脚本 + systemd 服务文件 `scripts/a-share-research.service`。
+
+## 关键约束与事实
+
+- LLM 模型 `deepseek-v4-flash` 绝不允许修改(记忆:never-change-llm-model)
+- 生产部署:Pi `pi@192.168.1.160:/home/pi/news/`,systemd 服务 `a-share-research`;改动后需 scp 同步
+- 改 sources.yaml 前先从 Pi 拉取、改完推回(记忆:sync-sources-yaml)
+- 配置在 configs/sources.yaml、configs/watchlist.yaml、.env;API Key 只放 .env
+- Prompt 在 prompts/*.md,禁止写死在代码中
+- 会话恢复上下文以 continuation.md 为准
+
—— CLAUDE.md 结束 ——
diff --git a/README.md b/README.md
index 5ad2bfd..6d783de 100644
--- a/README.md
+++ b/README.md
@@ -26,9 +26,9 @@
## 当前状态
-| 已完成 | M0~M8、**M9 投研 Agent Prompt** |
+| 已完成 | M0~M8、M9 投研 Agent Prompt、**M10 日报结构化入库** |
| --- | --- |
-| 进行中 | 等待 M9 验收 |
+| 进行中 | 等待 M10 验收 |
| 下一步 | 项目整合与优化 |
详见 `continuation.md`。开发遵循 `project_plan.md` 9 个 Milestone,分阶段交付。
@@ -110,9 +110,10 @@ uv run a-share search "宁德时代固态电池"
uv run a-share search "政策" --source cls --sentiment positive
uv run a-share search "风险" --stock 001212 --min-importance 3
-# 日报(生成 HTML 报告并上传)
-uv run a-share report # 生成今日日报
+# 日报(结构化入库, 不再生成 HTML; API/前端另行实现, 读取 MySQL news_ 表)
+uv run a-share report # 生成今日日报并入 myquant 库
uv run a-share report --date 20260616 # 指定日期
+uv run a-share report-import # 历史日报 HTML 解析入库(幂等)
uv run a-share pipeline --once --report # 全链路末尾自动生成日报
# 状态总览
diff --git a/a_share_cli/main.py b/a_share_cli/main.py
index 3afc955..a581eab 100644
--- a/a_share_cli/main.py
+++ b/a_share_cli/main.py
@@ -18,6 +18,7 @@ from __future__ import annotations
import argparse
import json
+import os
import subprocess
import sys
from collections import Counter
@@ -394,24 +395,34 @@ def cmd_status(args: argparse.Namespace) -> int: # noqa: ARG001
# --------------------------------------------------------------------------- #
def cmd_report(args: argparse.Namespace) -> int:
- """生成 HTML 日报并上传到 Web 服务器。"""
+ """生成日报并结构化入库(M10:不再生成 HTML/上传)。"""
load_dotenv()
from scheduler.reporter import generate_report
day_str = args.date or _today()
print(f"📊 生成日报: {day_str}")
- path = generate_report(day_str, upload=not args.no_upload)
- if path is None:
+ report_id = generate_report(day_str, upload=not args.no_upload)
+ if report_id is None:
print("⚠️ 无数据或生成失败")
return 1
- print(f"✅ 日报已保存: {path}")
- if not args.no_upload:
- from datetime import date as dt_date
- today_str = dt_date.today().strftime("%Y%m%d")
- print(f"✅ 已上传: http://doorcome.cn/echart/research/{today_str}/")
+ print(f"✅ 日报已入库: report_id={report_id}")
return 0
+def cmd_report_import(args: argparse.Namespace) -> int:
+ """历史日报 HTML 解析入库(M10)。"""
+ load_dotenv()
+ from report_import.importer import import_history
+
+ report_dir = args.dir or os.environ.get("REPORT_HISTORY_DIR", "data/reports_history")
+ print(f"📥 导入历史日报: {report_dir} (date={args.date or '全部'} type={args.type or '全部'})")
+ stats = import_history(report_dir, date_str=args.date, report_type=args.type, force=args.force)
+ print(f"✅ 扫描 {stats.scanned} | 新导入 {stats.imported} | 跳过 {stats.skipped} | 失败 {stats.failed}")
+ for err in stats.errors[:20]:
+ print(f" ❌ {err}")
+ return 0 if stats.failed == 0 else 1
+
+
# --------------------------------------------------------------------------- #
# discover — 自动分析站点,生成 sources.yaml 配置建议
# --------------------------------------------------------------------------- #
@@ -917,6 +928,14 @@ def main() -> int:
pl = sp.add_parser("list", help="查看关注列表")
pl.set_defaults(func=cmd_watchlist_list)
+ # ---- report-import ----
+ p = sub.add_parser("report-import", help="历史日报 HTML 解析入库 (M10)")
+ p.add_argument("--dir", default=None, help="日报目录(默认 REPORT_HISTORY_DIR)")
+ p.add_argument("--date", default=None, help="仅导入指定日期 YYYYMMDD")
+ p.add_argument("--type", default=None, choices=["finance", "intl"], help="仅导入指定类型")
+ p.add_argument("--force", action="store_true", help="已存在也覆盖重导")
+ p.set_defaults(func=cmd_report_import)
+
# ---- stock-report ----
p = sub.add_parser("stock-report", help="生成关注列表个股日报")
p.add_argument("--no-upload", action="store_true", help="仅生成不上传")
diff --git a/continuation.md b/continuation.md
index 3eeb9fb..ad896b6 100644
--- a/continuation.md
+++ b/continuation.md
@@ -1,6 +1,6 @@
# continuation.md
-> `checkpoint` @ 2026-07-17 07:51
+> `checkpoint` @ 2026-08-03 21:30
---
@@ -10,7 +10,7 @@
| --- | --- |
| 新闻源 | 14 个(13 Web + 1 API: xwlb 新闻联播) |
| Qdrant | 本地文件模式 `data/qdrant_storage/` |
-| 日报 | 5 板块: AI摘要 / 新闻联播 / 财经新闻 / 公告调研 / 数据总览 |
+| 日报 | **M10 完成: 结构化入库 MySQL(myquant 库 news_report/news_event 表),不再生成 HTML** |
| 调度器 | APScheduler,systemd `a-share-research.service` |
| LLM | `deepseek-v4-flash`(绝不允许擅自修改) |
| 服务器 | `pi@192.168.1.160`,项目 `/home/pi/news/` |
@@ -18,7 +18,40 @@
---
-## 本次完成 (2026-07-17) — 禁用个股日报
+## 本次完成 (2026-08-03) — M10 日报结构化入库
+
+**目标:** 日报前后端分离的数据层——日报内容结构化存入 MySQL(`news_` 前缀表),历史 178 份日报 HTML 解析入库;本项目不做 API/前端(用户另行实现)。
+
+**表结构(myquant 库,见 docs/db_schema.md):**
+- `news_report`:主表,唯一键 (report_date, report_type, file_name);`file_name=''` 表示新生成日报(每天每类型一行,重复生成覆盖)
+- `news_event`:事件明细,section ∈ xwlb/news/cninfo/intl
+
+**新增/修改文件:**
+- `report_db/`(models/schema/db):连接、建表、幂等 save_report
+- `report_import/`(parser/importer):历史 HTML 解析(表头驱动列映射)+ 批量导入
+- `scheduler/reporter.py`:`generate_report()` 完全切换为结构化入库;`_render_html/_upload` 标记废弃保留
+- `a_share_cli/main.py`:新增 `report-import` 子命令;`report` 命令改为入库提示
+- `pyproject.toml`:`uv add pymysql`(唯一新增依赖)
+- `.env`:新增 `NEWS_DB_*`(连接 127.0.0.1:13306,需 ssh 隧道)、`REPORT_HISTORY_DIR`
+- `docs/report_db_design.md`(实现逻辑)、`docs/db_schema.md`(表结构,供 API/前端)
+
+**验证结果:**
+- 历史 178 份全部入库:scanned=178 imported=7(首轮)+170 skipped=171 failed=0;DB 177 行(finance 49 + intl 128,1 个跨目录同名文件被幂等合并)+ 事件 4222 条
+- 端到端:`a-share report --date 20260616` → report_id=180 入库成功,无 HTML 产出
+- 单测 24 个通过(parser 19 + builder 4 + models/import 补充)
+
+**命令:**
+```bash
+ssh -L 13306:127.0.0.1:13306 pi # Mac 开发连库隧道
+uv run a-share report --date YYYYMMDD # 生成日报入库
+uv run a-share report-import # 历史导入(幂等)
+```
+
+**注意:** 本机 .env 无 LLM API key,AI 摘要会降级 WARNING(不影响入库);生产 pi5 需配置 NEWS_DB_PASSWORD 且解决 13306 隧道可达性(见待确认项)。
+
+---
+
+## 历史 (2026-07-17) — 禁用个股日报
**操作:** `STOCK_REPORT_TIME=` 设为空,`run_scheduler.py` 加空值守卫。
diff --git a/docs/db_schema.md b/docs/db_schema.md
new file mode 100644
index 0000000..45bc28b
--- /dev/null
+++ b/docs/db_schema.md
@@ -0,0 +1,111 @@
+# 日报结构化入库:数据库表结构与数据契约
+
+> 版本:v1.0 | 2026-08-03
+> 用途:供 API / 前端对接读取日报数据。表位于 MySQL `myquant` 库,表前缀 `news_`。
+> 连接:`192.168.1.10:13306`(pi 上 autossh 隧道 → doorcome.cn:3306 MariaDB 10.11),用户 `myquant`(密码在服务器 `.env` 的 `NEWS_DB_PASSWORD`)。
+
+---
+
+## 1. 表结构
+
+### 1.1 news_report(日报主表,一行 = 一份日报)
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| id | BIGINT UNSIGNED PK | 自增主键 |
+| report_date | DATE | 日报日期 |
+| report_type | VARCHAR(16) | `finance`=A 股日报 / `intl`=国际财经日报 |
+| file_name | VARCHAR(160) | 历史文件源文件名;**新生成日报为空字符串 `""`** |
+| generated_at | DATETIME | 生成时间 |
+| ai_summary | TEXT | AI 摘要全文(含换行,按条目分行) |
+| stats | JSON | 数据总览统计快照(见第 3 节),可为 NULL |
+| created_at | DATETIME | 入库时间 |
+
+唯一键:`(report_date, report_type, file_name)` —— 历史同一天多次生成(intl 一日 3 次)保留多行;新生成日报 `file_name=''` 每天每类型仅一行,重复生成覆盖。
+
+### 1.2 news_event(日报事件明细,一行 = 一条事件)
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| id | BIGINT UNSIGNED PK | 自增主键 |
+| report_id | BIGINT UNSIGNED | FK → news_report.id |
+| section | VARCHAR(16) | 板块:`xwlb`=新闻联播 / `news`=财经新闻 / `cninfo`=公告调研 / `intl`=国际重要事件 |
+| rank | INT | 板块内序号(1 起) |
+| importance | INT NULL | 重要度 1-5 |
+| event_type | VARCHAR(64) NULL | 事件类型(如 宏观经济/地缘政治/新闻联播/公告) |
+| title | VARCHAR(512) | 标题 |
+| summary | TEXT NULL | 摘要/正文 |
+| sentiment | VARCHAR(8) NULL | `positive` / `negative` / `neutral` |
+| source | VARCHAR(64) NULL | 来源(如 `cls`、`investinglive.com`) |
+| url | VARCHAR(512) NULL | 原文链接(新闻联播为空) |
+| created_at | DATETIME | 入库时间 |
+
+索引:`idx_report_section (report_id, section)`。
+
+---
+
+## 2. 数据契约
+
+- **幂等语义**:同一 `(report_date, report_type, file_name)` 重复写入会覆盖主表并全量替换事件(DELETE + INSERT),不会产生重复行。
+- **取最新**:同一天存在多份时(历史 intl 一日 3 次),前端按 `generated_at` 取最新;新日报 `file_name=''` 每天唯一。
+- **板块差异**:finance 日报含 `xwlb`+`news`+`cninfo` 三板块;intl 日报仅 `intl` 板块。前端按 `section` 过滤展示。
+- **历史覆盖范围**:2026-06-16 ~ 2026-08-03,共 177 行(finance 49 + intl 128;finance 少 1 因为两个目录存在同名文件被幂等合并)。事件总计 4222 条。
+
+---
+
+## 3. stats JSON 结构
+
+`news_report.stats` 为数据总览快照,前端自行解析。finance 与 intl 的 key 集合不同:
+
+| key | finance | intl | 内容 |
+| --- | --- | --- | --- |
+| `pipeline` | ✅ | ✅ | M1→M6 管道各环节数量:`{label: 数量}` |
+| `sources` | ✅ | — | 各新闻源文章数:`{源名: 数量}` |
+| `news` | ✅ | — | 新闻统计:`{total, hi_threshold, sentiments, importances, event_types}` |
+| `cninfo` | ✅ | — | 公告调研统计:`{total, hi_threshold, by_day, announcement, research, irm}` |
+| `xwlb` | ✅ | — | 联播统计:`{total, date}`(有数据时才有) |
+| `sentiment` | ✅ | ✅ | 情绪分布(历史文件为图例文本列表;新生成在 `news.sentiments`) |
+| `importance` | ✅ | ✅ | 重要度分布:`[{重要度, 数量}, ...]` |
+| `event_types` | ✅ | ✅ | 事件类型 TOP:`[{事件类型, 数量}, ...]` |
+| `source_dist` | — | ✅ | 文章来源分布:`[{来源, 文章数}, ...]` |
+
+> 历史文件与新生成日报的 stats 结构存在差异(历史为 HTML 解析快照,新生成为结构化组装),前端建议按 key 防御性读取。
+
+---
+
+## 4. 常用查询示例(API 实现参考)
+
+```sql
+-- 某类型日报列表(取每天最新一份)
+SELECT r.* FROM news_report r
+JOIN (
+ SELECT report_date, report_type, MAX(generated_at) AS g
+ FROM news_report GROUP BY report_date, report_type
+) t ON r.report_date = t.report_date AND r.report_type = t.report_type
+ AND r.generated_at = t.g
+WHERE r.report_type = 'finance' AND r.report_date >= '2026-07-01'
+ORDER BY r.report_date DESC;
+
+-- 某日报的全部事件(按板块)
+SELECT section, rank, importance, event_type, title, summary, sentiment, source, url
+FROM news_event WHERE report_id = ? ORDER BY section, rank;
+
+-- 最近 N 天重要事件聚合(跨日报检索)
+SELECT e.* FROM news_event e
+JOIN news_report r ON r.id = e.report_id
+WHERE r.report_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
+ AND e.importance >= 4
+ORDER BY e.importance DESC, r.report_date DESC;
+```
+
+---
+
+## 5. 相关命令(数据生产侧)
+
+```bash
+uv run a-share report --date YYYYMMDD # 生成当日日报并入库(finance)
+uv run a-share report-import # 历史 HTML 全量解析入库(幂等)
+uv run a-share report-import --date YYYYMMDD --type intl
+```
+
+代码:`report_db/`(连接/写入)、`report_import/`(历史解析/导入)、`scheduler/reporter.py`(日报生成)。
diff --git a/docs/report_db_design.md b/docs/report_db_design.md
new file mode 100644
index 0000000..3626b34
--- /dev/null
+++ b/docs/report_db_design.md
@@ -0,0 +1,330 @@
+# Milestone 10 后端实现逻辑:日报结构化入库
+
+> 版本:v0.1(设计稿) | 2026-07
+> 对应 project_plan.md「十八、Milestone 10」
+> **范围**:本项目侧"后端"= 数据生产层(日报内容生成 + 结构化写入 MySQL)。
+> 不包含 API 服务与前端页面(由用户另行实现),但表结构与数据契约以本文档为准,供 API/前端对接。
+
+---
+
+## 1. 定位
+
+现有链路:`reporter.py` 收集数据 → `_render_html()` 渲染 HTML → scp 上传 doorcome。
+改造后:`reporter.py` 收集数据 → 组装结构化 `ReportData` → 写入 MySQL(`news_report` / `news_event`),不再产出 HTML。
+
+另需:把 doorcome 上 178 份历史日报 HTML(`finance_news_daily_*` ×50、`intl_news_daily_*` ×128)解析成同一 `ReportData` 结构入库。
+
+---
+
+## 2. 数据流总览
+
+```
+[历史 HTML ×178] [每日 pipeline]
+ doorcome:/var/www/html/echart/research/ crawler→extractor→dedup→llm→embed→qdrant
+ (一次性 scp 到 data/reports_history/) │
+ │ ▼
+ ▼ reporter.generate_report()
+ report_import/parser.py │
+ │ (BeautifulSoup 解析) ▼
+ ▼ 组装 ReportData 组装 ReportData
+ report_import/importer.py │
+ │ (幂等 upsert) ▼
+ ▼ │
+ ┌────────────────────── MySQL (myquant 库) ──────────────────────┐
+ │ news_report(主表) news_event(事件明细) │
+ └────────────────────────────────────────────────────────────────┘
+ ▲
+ API / 前端(用户另行实现,只读)
+```
+
+---
+
+## 3. 数据模型(Pydantic,`report_db/models.py`)
+
+```python
+class EventRow(BaseModel):
+ """一条事件记录,对应 news_event 一行。"""
+ section: str # xwlb | news | cninfo | intl
+ rank: int # 板块内序号(从 1 开始)
+ importance: int | None = None
+ event_type: str | None = None
+ title: str
+ summary: str | None = None
+ sentiment: str | None = None # positive | negative | neutral | ''
+ source: str | None = None # 来源(如 cls / ForexLive)
+ url: str | None = None
+
+class ReportData(BaseModel):
+ """一份完整日报,对应 news_report 一行 + news_event 多行。"""
+ report_date: date # 日报日期(YYYY-MM-DD)
+ report_type: str # finance | intl
+ file_name: str # 源文件名(新生成时可为 "")
+ generated_at: datetime # 生成时间
+ ai_summary: str | None = None
+ stats: dict[str, Any] = Field(default_factory=dict) # 数据总览统计快照 → JSON 列
+ events: list[EventRow] = Field(default_factory=list)
+```
+
+---
+
+## 4. 字段映射(核心契约)
+
+### 4.1 事件 JSON(data/events/)→ news_event
+
+现有事件文件结构与 news_event 字段对应关系(reporter 收集时直接转换):
+
+| news_event 字段 | 事件 JSON 来源 |
+| --- | --- |
+| section | 来源判定:`source_id=="cninfo"` → `cninfo`;`source_id=="xwlb"` → `xwlb`;否则 `news`;intl 解析固定 `intl` |
+| importance | `event.importance` |
+| event_type | `event.event_type` |
+| title | `title` |
+| summary | `event.summary` |
+| sentiment | `event.sentiment` |
+| source | `source_id` |
+| url | `url`(xwlb 为空) |
+
+### 4.2 历史 HTML → ReportData
+
+解析策略:**表头驱动列映射**。不同日报表格列集合不同:
+
+| 板块 | 表格列(
) | section |
+| --- | --- | --- |
+| 新闻联播(finance) | `# / (空) / 标题 / 重要度 / 事件类型` | xwlb |
+| 重要事件:新闻(finance) | `# / (空) / 标题 / 源 / 重要度 / 事件类型 / 摘要` | news |
+| 重要事件:公告调研(finance) | 同上 | cninfo |
+| 重要事件(intl) | `# / (空) / 标题 / 重要度 / 事件类型 / 摘要` | intl |
+
+要点:
+
+- 以表头文本定位列索引("标题""重要度""事件类型""摘要""源"),空 ` | ` 为情绪图标列(⚪/🔴/🟢 → neutral/negative/positive),**不要依赖列位置**。
+- 情绪图标仅存在于有图标列的表;intl 表情绪列存在,finance 表情绪列存在(空 th 首列后)。
+- intl 无"源"列时,尝试从标题尾部 `[来源]` 或摘要尾部提取,提取不到则 `source=None`。
+- 标题中的股票代码标注 `(600519, ...)` 与 ⭐(自选股标记)需剥除,只保留纯标题。
+- AI 摘要:取 `h2`("一、AI 摘要")之后紧随的 `.ai-summary` 区块纯文本(保留换行)。
+- 数据总览 → `stats` JSON:按 `h3` 标题映射 key(见 4.3),解析该 h3 后的首个 ``,缺失的板块跳过、不报错。
+- 容错:任一板块解析失败 → 记 WARNING 日志,该板块置空,不影响整份入库;整份文件解析失败 → 抛 `ReportParseError`(由 importer 捕获计数)。
+
+### 4.3 数据总览 → stats JSON
+
+| h3 标题(含板块名) | stats key |
+| --- | --- |
+| M1→M6 管道 / 管道 | `pipeline`(保留原始行) |
+| 各源数据 | `sources` |
+| 情绪分布 | `sentiment` |
+| 重要度分布 | `importance` |
+| 事件类型(TOP 10 / 分布) | `event_types` |
+| 文章来源分布 | `source_dist` |
+
+`stats` 存 MySQL `JSON` 列,前端自行解析展示。历史文件与未来新日报的 stats 结构可能不同(finance 与 intl 板块不同),一律按快照存储,不做跨版本规范化。
+
+---
+
+## 5. 模块设计
+
+### 5.1 新包 `report_db/`(DB 层)
+
+```
+report_db/
+├── __init__.py # 导出 connect / init_schema / save_report
+├── models.py # EventRow / ReportData(Pydantic)
+├── schema.py # DDL 常量(news_report / news_event,见 project_plan.md 十八)
+└── db.py # 连接、事务、写入
+```
+
+`db.py` 关键函数:
+
+```python
+def load_db_config() -> DbConfig:
+ """从环境变量读取 NEWS_DB_HOST/PORT/USER/PASSWORD/NAME。
+ 缺失 PASSWORD 时记 ERROR 并 raise,禁止默认密码。"""
+
+def connect(cfg: DbConfig) -> Connection:
+ """pymysql.connect(autocommit=False, charset="utf8mb4", cursorclass=DictCursor)。
+ 失败时 logger.exception + raise。"""
+
+def init_schema(conn: Connection) -> None:
+ """执行 schema.py 中的 CREATE TABLE IF NOT EXISTS ×2。"""
+
+def save_report(conn: Connection, report: ReportData) -> int:
+ """事务内:
+ 1. INSERT INTO news_report (...) VALUES (...) 或按 (report_date, report_type, file_name)
+ 唯一键命中时 UPDATE(新生成日报重复执行 = 覆盖同 file_name/同日期,幂等);
+ 2. 取 report_id,DELETE 旧事件后批量 INSERT news_event(保证整份覆盖一致)。
+ 返回 report_id。"""
+
+def transaction(conn: Connection) -> contextmanager:
+ """提交/回滚上下文管理器。"""
+
+def fetch_report(conn: Connection, report_id: int) -> dict | None:
+ """读侧辅助(联调/测试用),API 侧由用户自行实现。"""
+```
+
+要点:
+
+- 所有 SQL 为 MySQL/MariaDB 方言(`JSON` 列、`ENGINE=InnoDB`、`COMMENT`),**不依赖 ORM**。
+- 连接生命周期:每次 `save_report` 短连接(report 一天跑几次,量小,无需连接池;如未来加大再换)。
+- 字符集 utf8mb4,`SET NAMES utf8mb4` 由 pymysql charset 参数处理。
+
+### 5.2 新包 `report_import/`(历史解析)
+
+```
+report_import/
+├── __init__.py
+├── parser.py # parse_finance_report / parse_intl_report(BeautifulSoup)
+└── importer.py # import_history(dir, date=None, type=None) -> ImportStats
+```
+
+`parser.py`:
+
+```python
+class ReportParseError(Exception): ...
+
+def parse_finance_report(html: str, file_name: str) -> ReportData: ...
+def parse_intl_report(html: str, file_name: str) -> ReportData: ...
+def parse_report(html: str, file_name: str) -> ReportData:
+ """按文件名前缀分流:finance_news_daily_* / intl_news_daily_*。"""
+```
+
+- 依赖复用现有 `beautifulsoup4`(已在 pyproject 依赖),**不新增解析库**。
+- `report_date` 从文件名解析(`*_daily_{YYYYMMDD}_*.html`),不信任目录名。
+- `generated_at` 从文件名时间(`{HHMMSS}`)或 `` 中"生成于"文本解析,解析不到用文件 mtime。
+
+`importer.py`:
+
+```python
+@dataclass
+class ImportStats:
+ scanned: int = 0 # 扫描到的日报文件数
+ imported: int = 0 # 新入库
+ skipped: int = 0 # 已存在(幂等跳过)
+ failed: int = 0 # 解析失败
+ errors: list[str] = field(default_factory=list)
+
+def import_history(report_dir: Path, date: str | None = None,
+ report_type: str | None = None) -> ImportStats:
+ """遍历 {report_dir}/{YYYYMMDD}/*_news_daily_*.html,
+ 过滤 date / type,逐个 parse → save_report。"""
+```
+
+### 5.3 `scheduler/reporter.py` 改造(完全切换)
+
+- 新增 `_build_report_data(news, cninfo, pipeline, ai_summary, day_str, xwlb) -> ReportData`:
+ - 事件转换:`news["high"]` → `EventRow(section="news", ...)`;`cninfo["high"]` → `section="cninfo"`;`xwlb["items"]` → `section="xwlb"`;
+ - `stats` 组装:`{"pipeline": pipeline, "sources": {...}, "sentiment": news["sentiments"], "importance": news["importances"], "event_types": news["event_types"], "cninfo": {...}}`;
+ - 事件 `rank` 按板块内顺序编号。
+- `generate_report(day_str, *, upload=True)` 改为:收集(逻辑不变)→ `_build_report_data` → `connect()` + `save_report()`;删除 `_render_html`/`_upload` 调用。
+- `_render_*` 函数**保留但标记 deprecated**(注释说明"完全切换后不再调用"),不删除,保证最小改动、可回退。
+- 返回值由 `Path | None` 改为 `report_id: int | None`;`scheduler/pipeline.py` 中 report 步骤仅判断非 None(实施时核实该处调用,保持兼容)。
+- `stock_reporter.py` **不改动**(个股日报不在本期范围)。
+
+### 5.4 `a_share_cli/main.py` 新增子命令
+
+```
+uv run a-share report-import [--dir data/reports_history] [--date YYYYMMDD] [--type finance|intl]
+```
+
+- 默认全量扫描 `REPORT_HISTORY_DIR`(.env 可配,默认 `data/reports_history/`)。
+- 输出 ImportStats 汇总(扫描/导入/跳过/失败)。
+
+---
+
+## 6. 关键流程
+
+### 6.1 历史导入(一次执行,可重复)
+
+```
+1. scp -r doorcome:/var/www/html/echart/research/2026* → data/reports_history/
+ (一次手工操作,不进代码)
+2. uv run a-share report-import
+ for each {date}/{file}:
+ report_type = 文件名前缀(finance|intl)
+ ReportData = parse_report(html, file_name)
+ try: save_report(conn, ReportData) → imported += 1
+ except DuplicateKey: skipped += 1 # 已导入过
+ except ReportParseError as e: failed += 1; errors.append(str(e))
+3. 校验: SELECT report_type, COUNT(*) FROM news_report GROUP BY report_type
+ 期望 50 / 128
+```
+
+### 6.2 每日日报生成(pipeline 07:00 步骤)
+
+```
+generate_report(day_str):
+ news = _collect_news_events(day_str) # 不变
+ cninfo = _collect_cninfo_events(day_str) # 不变
+ xwlb = _collect_xwlb(day_str) # 不变
+ pipeline = _collect_pipeline_stats(day_str) # 不变
+ ai_summary = _generate_ai_summary(...) # 不变
+ report = _build_report_data(...) # 新增
+ save_report(connect(), report) # 新增(替代渲染+上传)
+```
+
+### 6.3 幂等策略
+
+- 唯一键 `(report_date, report_type, file_name)`:
+ - 历史导入:命中 → 跳过(或 `--force` 覆盖);
+ - 新日报:`file_name=""` 时唯一键退化为 `(report_date, report_type, "")`,同一天重复跑 → UPDATE 覆盖,事件表 DELETE+INSERT 全量替换,**不产生历史残留**。
+
+---
+
+## 7. 配置项(.env / .env.example)
+
+```env
+# ---- 日报结构化入库 (M10) ----
+NEWS_DB_HOST=127.0.0.1 # 开发走 ssh 隧道: ssh -L 13306:127.0.0.1:13306 pi
+NEWS_DB_PORT=13306
+NEWS_DB_USER=myquant
+NEWS_DB_PASSWORD= # 填真实值,禁止写入源码/文档
+NEWS_DB_NAME=myquant
+REPORT_HISTORY_DIR=data/reports_history
+```
+
+---
+
+## 8. 错误处理
+
+| 场景 | 行为 |
+| --- | --- |
+| DB 不可达/凭据错误 | `connect()` 抛异常 → `generate_report` 记 ERROR 并返回 None(pipeline 该步骤失败,其余步骤不受影响) |
+| 单份历史文件解析失败 | 记 WARNING,`failed += 1`,继续下一份;结束输出失败清单 |
+| 事件字段缺失(如无摘要列) | 对应字段留 None,不抛错 |
+| 全部失败 | `report-import` 返回非 0 退出码,便于排查 |
+
+---
+
+## 9. 测试策略(tests/)
+
+| 文件 | 内容 |
+| --- | --- |
+| `tests/test_report_parser.py` | 用 fixtures(从 178 份中拷贝 finance/intl 各 1 份真实样例到 `tests/fixtures/`)断言:板块数、事件行数、字段映射、标题净化、幂等文件日期解析 |
+| `tests/test_report_db.py` | 纯逻辑:`_build_report_data` 组装正确;SQL 层用 sqlite3 内存库建同构(简化 DDL)验证 upsert/覆盖语义 |
+| `tests/test_report_import.py` | 临时目录构造 2-3 份假 HTML → 全流程导入 → 断言 ImportStats 计数与幂等 |
+| 集成(`@pytest.mark.integration`,默认跳过) | 连真实 MySQL:init_schema + save_report + 查询回读 |
+
+新增 pytest marker 说明:真实 DB 连接一律走 integration,**单元测试不得依赖生产库**。
+
+---
+
+## 10. 依赖变更
+
+- `uv add pymysql`(纯 Python 驱动,唯一新增依赖)
+- 解析复用现有 `beautifulsoup4`,不新增
+
+---
+
+## 11. 开放问题(沿自 project_plan.md 十八,不阻塞开发)
+
+1. 生产连接:pi5 无法直连 `192.168.1.10:13306`(隧道仅绑 loopback)——需决定改 pi 的 autossh 绑定 / pi5 自建隧道。
+2. intl 日报生成方不在本项目,未来 intl 新日报需按同一表结构写入(本项目仅负责解析历史 + finance 新日报)。
+3. 个股日报(research 根目录文件)本期不处理。
+
+---
+
+## 12. 实施顺序(供开发排期)
+
+1. `report_db/`(models/schema/db)+ `.env` 配置 + 建表验证
+2. `report_import/parser.py` + fixtures + 单测
+3. `report_import/importer.py` + CLI `report-import` + 178 份全量导入验收
+4. `reporter.py` 改造(_build_report_data + save_report)+ pipeline 兼容性验证
+5. docs/db_schema.md 定稿(给 API/前端)、README / continuation.md 更新
diff --git a/project_plan.md b/project_plan.md
index 780b618..cd29bce 100644
--- a/project_plan.md
+++ b/project_plan.md
@@ -602,5 +602,99 @@ Claude Code 必须严格遵守:
系统自动检索知识库、分析新闻事件,并生成完整研究报告。
+---
+
+# 十八、Milestone 10:日报结构化入库(前后端分离数据层)
+
+> 状态:✅ 已实施(2026-08-03,等待人工验收)
+> 范围:本项目只负责「日报内容生成 + 结构化存入 MySQL」,**不实现 API 与前端**(由用户另行实现)。
+
+## 背景与决策
+
+| 决策点 | 结论 |
+| --- | --- |
+| DB | 192.168.1.10:13306(pi 上 autossh 隧道 → doorcome.cn:3306 MariaDB 10.11.18),业务库 `myquant`,表前缀 `news_` |
+| API / 前端 | 用户另行实现,本项目只保证数据完整、表结构文档清晰 |
+| 日报生成 | 完全切换:只存 DB + 前端渲染,不再生成 HTML 静态文件 |
+| 历史数据 | doorcome `/var/www/html/echart/research/{YYYYMMDD}/` 下 178 份 `*_news_daily_*.html`(finance×50 + intl×128,日期 20260608~20260803),解析入库 |
+
+## 表结构设计
+
+### news_report(日报主表)
+
+```sql
+CREATE TABLE IF NOT EXISTS news_report (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ report_date DATE NOT NULL COMMENT '日报日期',
+ report_type VARCHAR(16) NOT NULL COMMENT 'finance=A股日报 / intl=国际财经日报',
+ file_name VARCHAR(160) NOT NULL DEFAULT '' COMMENT '源文件名(历史解析);新生成可为空',
+ generated_at DATETIME NOT NULL COMMENT '生成时间',
+ ai_summary TEXT NULL COMMENT 'AI 摘要全文',
+ stats JSON NULL COMMENT '数据总览统计快照(管道/情绪/重要度/事件类型/来源分布)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_report_file (report_date, report_type, file_name),
+ KEY idx_report_date (report_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每日日报主表';
+```
+
+### news_event(日报事件明细,新闻联播/财经新闻/公告调研/intl 事件统一入此表)
+
+```sql
+CREATE TABLE IF NOT EXISTS news_event (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ report_id BIGINT UNSIGNED NOT NULL COMMENT 'FK → news_report.id',
+ section VARCHAR(16) NOT NULL COMMENT '板块: xwlb=新闻联播 / news=财经新闻 / cninfo=公告调研 / intl=国际重要事件',
+ rank INT NOT NULL DEFAULT 0 COMMENT '板块内序号',
+ importance INT NULL COMMENT '重要度 1-5',
+ event_type VARCHAR(64) NULL COMMENT '事件类型',
+ title VARCHAR(512) NOT NULL COMMENT '标题',
+ summary TEXT NULL COMMENT '摘要/正文',
+ sentiment VARCHAR(8) NULL COMMENT 'positive/negative/neutral',
+ source VARCHAR(64) NULL COMMENT '来源',
+ url VARCHAR(512) NULL COMMENT '原文链接',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_report_section (report_id, section)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='日报事件明细';
+```
+
+设计说明:
+
+- 数据总览统计(M1→M6 管道、各源数据、情绪/重要度/事件类型/来源分布)以 JSON 快照存 `news_report.stats`,前端自行解析。
+- 幂等:导入/生成按 `(report_date, report_type, file_name)` 唯一键,重复执行跳过或覆盖,不产生重复行。
+- 历史同一天多次生成(intl 一日 3 次)保留多行,前端取最新。
+
+## 阶段划分(每阶段验收后进入下一阶段)
+
+### M10-A:DB 连接层与建表
+
+- `uv add pymysql`(纯 Python 驱动,无编译依赖)
+- 新包 `report_db/`:`db.py`(连接、事务、建表)、`schema.py`(DDL)
+- `.env` 新增 `NEWS_DB_HOST / NEWS_DB_PORT / NEWS_DB_USER / NEWS_DB_PASSWORD / NEWS_DB_NAME`(默认 myquant),`.env.example` 同步
+- 验收:连接成功、`news_report` / `news_event` 建表成功;`tests/test_report_db.py` 通过(测试用 sqlite3 内存模拟同构 SQL,真实 MySQL 走 integration 标记)
+
+### M10-B:历史日报解析入库
+
+- 一次性将 doorcome 178 份 HTML 拉到 `data/reports_history/`
+- 新包 `report_import/`:`parser.py`(finance/intl 两类 HTML → 结构化 dict,容错缺板块)、`importer.py`(幂等入库)
+- CLI 子命令:`a-share report-import [--date YYYYMMDD] [--type finance|intl]`(默认全量)
+- 验收:178 份全部入库,`SELECT COUNT(*)` 与文件数一致;抽查 finance/intl 各 2 份字段正确;重复执行不产生重复行
+
+### M10-C:日报生成改造(完全切换)
+
+- `scheduler/reporter.py`:`generate_report()` 改为「收集结构化数据 → 写入 news_report/news_event」,移除 `_render_html` / `_upload` 调用(函数可保留但不再触发)
+- `scheduler/pipeline.py` 调用签名保持兼容(仍调 `generate_report(day_str)`)
+- 验收:`uv run a-share report --date YYYYMMDD` 后 DB 出现当日记录且无 HTML 文件产出;AI 摘要、事件行、stats JSON 完整
+
+### M10-D:文档与收尾
+
+- `docs/db_schema.md`:完整表结构 + 字段说明 + 示例数据(供用户实现 API/前端)
+- 更新 README、continuation.md;提交信息 `feat: 日报结构化入库`
+
+## 待确认项(不阻塞开发,部署前需用户决策)
+
+1. **生产连接**:pi5(192.168.1.160)无法直连 `192.168.1.10:13306`(隧道只绑 loopback)。可选:a) pi 的 autossh 改为绑定 0.0.0.0(需改 pi 系统配置)b) pi5 自建隧道 c) 其他
+2. **intl 日报生成方**:项目代码中无 intl 生成逻辑(doorcome 上另有来源)。历史解析照做;未来 intl 新日报如需入库,由生成方按同一表结构写入
+3. **个股日报**(002714.SZ_0724.html 等 research 根目录文件):本期不处理,如需请另行提出
+
—— project_plan.md 结束 ——
diff --git a/pyproject.toml b/pyproject.toml
index 5d96841..04b933d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,7 @@ dependencies = [
"mcp>=1.0",
"pypdf>=6.13.3",
"markitdown[all]>=0.1.5",
+ "pymysql>=1.2.0",
]
[project.optional-dependencies]
diff --git a/report_db/__init__.py b/report_db/__init__.py
new file mode 100644
index 0000000..a4084d5
--- /dev/null
+++ b/report_db/__init__.py
@@ -0,0 +1,19 @@
+"""日报结构化入库(Milestone 10)。
+
+职责:日报内容(finance/intl)结构化后写入 MySQL(news_report / news_event),
+供用户另行实现的 API/前端读取。本项目不提供 API 与前端。
+"""
+
+from .db import connect, exists_report, fetch_report, init_schema, load_db_config, save_report
+from .models import EventRow, ReportData
+
+__all__ = [
+ "EventRow",
+ "ReportData",
+ "connect",
+ "exists_report",
+ "fetch_report",
+ "init_schema",
+ "load_db_config",
+ "save_report",
+]
diff --git a/report_db/db.py b/report_db/db.py
new file mode 100644
index 0000000..e1993ff
--- /dev/null
+++ b/report_db/db.py
@@ -0,0 +1,156 @@
+"""日报结构化入库:DB 连接、建表、写入。"""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from typing import Any
+
+import pymysql
+from loguru import logger
+
+from .models import ReportData
+from .schema import DDL_STATEMENTS
+
+
+@dataclass(frozen=True)
+class DbConfig:
+ """MySQL 连接配置(来自环境变量 NEWS_DB_*)。"""
+
+ host: str
+ port: int
+ user: str
+ password: str
+ name: str
+
+
+def load_db_config() -> DbConfig:
+ """从环境变量读取 NEWS_DB_*,缺失密码时抛异常(禁止默认密码)。"""
+ host = os.environ.get("NEWS_DB_HOST", "127.0.0.1")
+ port = int(os.environ.get("NEWS_DB_PORT", "13306"))
+ user = os.environ.get("NEWS_DB_USER", "myquant")
+ password = os.environ.get("NEWS_DB_PASSWORD", "")
+ name = os.environ.get("NEWS_DB_NAME", "myquant")
+ if not password:
+ logger.error("NEWS_DB_PASSWORD 未配置,请在 .env 中设置")
+ raise ValueError("NEWS_DB_PASSWORD 未配置")
+ return DbConfig(host=host, port=port, user=user, password=password, name=name)
+
+
+def connect(cfg: DbConfig | None = None) -> pymysql.Connection:
+ """建立短连接(autocommit=False)。失败时记录日志并抛出。"""
+ cfg = cfg or load_db_config()
+ try:
+ conn = pymysql.connect(
+ host=cfg.host,
+ port=cfg.port,
+ user=cfg.user,
+ password=cfg.password,
+ database=cfg.name,
+ charset="utf8mb4",
+ autocommit=False,
+ cursorclass=pymysql.cursors.DictCursor,
+ )
+ except Exception:
+ logger.exception("连接 MySQL 失败: host={} port={} user={}", cfg.host, cfg.port, cfg.user)
+ raise
+ logger.debug("MySQL 已连接: {}/{}", cfg.host, cfg.name)
+ return conn
+
+
+def init_schema(conn: pymysql.Connection) -> None:
+ """建表(CREATE TABLE IF NOT EXISTS ×2),幂等。"""
+ with conn.cursor() as cur:
+ for ddl in DDL_STATEMENTS:
+ cur.execute(ddl)
+ conn.commit()
+ logger.info("news_report / news_event 建表完成")
+
+
+def save_report(conn: pymysql.Connection, report: ReportData) -> int:
+ """事务内写入一份日报。
+
+ 幂等策略:
+ - 主表按 (report_date, report_type, file_name) 唯一键 upsert;
+ - 事件表 DELETE 该 report 旧行后全量 INSERT(整份覆盖一致)。
+ 返回 report_id。
+ """
+ with conn.cursor() as cur:
+ stats_json = json.dumps(report.stats, ensure_ascii=False) if report.stats else None
+ cur.execute(
+ """
+ INSERT INTO news_report
+ (report_date, report_type, file_name, generated_at, ai_summary, stats)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ ON DUPLICATE KEY UPDATE
+ generated_at = VALUES(generated_at),
+ ai_summary = VALUES(ai_summary),
+ stats = VALUES(stats)
+ """,
+ (
+ report.report_date,
+ report.report_type,
+ report.file_name,
+ report.generated_at,
+ report.ai_summary,
+ stats_json,
+ ),
+ )
+ cur.execute(
+ "SELECT id FROM news_report WHERE report_date=%s AND report_type=%s AND file_name=%s",
+ (report.report_date, report.report_type, report.file_name),
+ )
+ row = cur.fetchone()
+ if row is None: # pragma: no cover - 理论不可达
+ raise RuntimeError("写入 news_report 后查询不到 report_id")
+ report_id: int = row["id"]
+
+ cur.execute("DELETE FROM news_event WHERE report_id=%s", (report_id,))
+ for ev in report.events:
+ cur.execute(
+ """
+ INSERT INTO news_event
+ (report_id, section, rank, importance, event_type, title,
+ summary, sentiment, source, url)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ report_id,
+ ev.section,
+ ev.rank,
+ ev.importance,
+ ev.event_type,
+ ev.title,
+ ev.summary,
+ ev.sentiment,
+ ev.source,
+ ev.url,
+ ),
+ )
+ conn.commit()
+ logger.info("日报已入库: report_id={} date={} type={} events={}",
+ report_id, report.report_date, report.report_type, len(report.events))
+ return report_id
+
+
+def fetch_report(conn: pymysql.Connection, report_id: int) -> dict[str, Any] | None:
+ """读侧辅助(联调/测试用),返回主表行。"""
+ with conn.cursor() as cur:
+ cur.execute("SELECT * FROM news_report WHERE id=%s", (report_id,))
+ return cur.fetchone()
+
+
+def exists_report(
+ conn: pymysql.Connection,
+ report_date: Any,
+ report_type: str,
+ file_name: str,
+) -> bool:
+ """判断主表是否已存在该唯一键记录(历史导入幂等用)。"""
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT 1 FROM news_report WHERE report_date=%s AND report_type=%s AND file_name=%s",
+ (report_date, report_type, file_name),
+ )
+ return cur.fetchone() is not None
diff --git a/report_db/models.py b/report_db/models.py
new file mode 100644
index 0000000..63b2e81
--- /dev/null
+++ b/report_db/models.py
@@ -0,0 +1,34 @@
+"""日报结构化入库:数据模型。"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class EventRow(BaseModel):
+ """一条事件记录,对应 news_event 一行。"""
+
+ section: str # xwlb | news | cninfo | intl
+ rank: int # 板块内序号(从 1 开始)
+ importance: int | None = None
+ event_type: str | None = None
+ title: str
+ summary: str | None = None
+ sentiment: str | None = None # positive | negative | neutral | ''
+ source: str | None = None
+ url: str | None = None
+
+
+class ReportData(BaseModel):
+ """一份完整日报:news_report 一行 + news_event 多行。"""
+
+ report_date: date
+ report_type: str # finance | intl
+ file_name: str = "" # 源文件名(新生成日报可为空)
+ generated_at: datetime
+ ai_summary: str | None = None
+ stats: dict[str, Any] = Field(default_factory=dict)
+ events: list[EventRow] = Field(default_factory=list)
diff --git a/report_db/schema.py b/report_db/schema.py
new file mode 100644
index 0000000..a6b951a
--- /dev/null
+++ b/report_db/schema.py
@@ -0,0 +1,43 @@
+"""MySQL DDL:日报结构化入库(表前缀 news_,目标 MariaDB 10.11)。
+
+与 project_plan.md「十八、Milestone 10」及 docs/report_db_design.md 保持一致。
+"""
+
+from __future__ import annotations
+
+DDL_STATEMENTS: list[str] = [
+ # 日报主表
+ """
+ CREATE TABLE IF NOT EXISTS news_report (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ report_date DATE NOT NULL COMMENT '日报日期',
+ report_type VARCHAR(16) NOT NULL COMMENT 'finance=A股日报 / intl=国际财经日报',
+ file_name VARCHAR(160) NOT NULL DEFAULT '' COMMENT '源文件名(历史解析);新生成可为空',
+ generated_at DATETIME NOT NULL COMMENT '生成时间',
+ ai_summary TEXT NULL COMMENT 'AI 摘要全文',
+ stats JSON NULL COMMENT '数据总览统计快照(管道/情绪/重要度/事件类型/来源分布)',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_report_file (report_date, report_type, file_name),
+ KEY idx_report_date (report_date)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每日日报主表'
+ """,
+ # 日报事件明细
+ """
+ CREATE TABLE IF NOT EXISTS news_event (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ report_id BIGINT UNSIGNED NOT NULL COMMENT 'FK → news_report.id',
+ section VARCHAR(16) NOT NULL COMMENT '板块: xwlb=新闻联播 / news=财经新闻 / cninfo=公告调研 / intl=国际重要事件',
+ rank INT NOT NULL DEFAULT 0 COMMENT '板块内序号',
+ importance INT NULL COMMENT '重要度 1-5',
+ event_type VARCHAR(64) NULL COMMENT '事件类型',
+ title VARCHAR(512) NOT NULL COMMENT '标题',
+ summary TEXT NULL COMMENT '摘要/正文',
+ sentiment VARCHAR(8) NULL COMMENT 'positive/negative/neutral',
+ source VARCHAR(64) NULL COMMENT '来源',
+ url VARCHAR(512) NULL COMMENT '原文链接',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_report_section (report_id, section),
+ KEY idx_title (title(255))
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='日报事件明细'
+ """,
+]
diff --git a/report_import/__init__.py b/report_import/__init__.py
new file mode 100644
index 0000000..824b7d2
--- /dev/null
+++ b/report_import/__init__.py
@@ -0,0 +1,21 @@
+"""历史日报解析与批量导入(Milestone 10)。
+
+将 doorcome 历史日报 HTML(finance/intl)解析为结构化数据并写入 MySQL。
+"""
+
+from .importer import ImportStats, import_history
+from .parser import (
+ ReportParseError,
+ parse_finance_report,
+ parse_intl_report,
+ parse_report,
+)
+
+__all__ = [
+ "ImportStats",
+ "ReportParseError",
+ "import_history",
+ "parse_finance_report",
+ "parse_intl_report",
+ "parse_report",
+]
diff --git a/report_import/importer.py b/report_import/importer.py
new file mode 100644
index 0000000..f851609
--- /dev/null
+++ b/report_import/importer.py
@@ -0,0 +1,89 @@
+"""历史日报批量导入(解析 → 入库,幂等)。"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from loguru import logger
+
+from report_db import connect, exists_report, save_report
+from report_import.parser import ReportParseError, parse_report
+
+_REPORT_FILE_RE = re.compile(r"([a-z]+)_news_daily_\d{8}(?:_\d{4,6})?\.html$")
+
+
+@dataclass
+class ImportStats:
+ """一次导入的统计结果。"""
+
+ scanned: int = 0 # 扫描到的日报文件数
+ imported: int = 0 # 新入库
+ skipped: int = 0 # 已存在(幂等跳过)
+ failed: int = 0 # 解析失败
+ errors: list[str] = field(default_factory=list)
+
+
+def _match_file(path: Path, date_str: str | None, report_type: str | None) -> bool:
+ """按文件名判断是否属于本次导入范围。"""
+ m = _REPORT_FILE_RE.search(path.name)
+ if not m:
+ return False
+ if report_type is not None and m.group(1) != report_type:
+ return False
+ return date_str is None or date_str in path.name
+
+
+def import_history(
+ report_dir: str | Path,
+ date_str: str | None = None,
+ report_type: str | None = None,
+ *,
+ force: bool = False,
+) -> ImportStats:
+ """扫描 {report_dir}/{YYYYMMDD}/ 下全部 `*_news_daily_*.html` 并入库。
+
+ - 幂等:主表唯一键 (report_date, report_type, file_name) 已存在则跳过;
+ - `force=True` 时跳过存在性检查,直接覆盖重导;
+ - 单文件解析失败不影响其他文件。
+ """
+ stats = ImportStats()
+ root = Path(report_dir)
+ if not root.is_dir():
+ logger.error("日报目录不存在: {}", root)
+ raise FileNotFoundError(f"日报目录不存在: {root}")
+
+ files = sorted(p for p in root.glob("*/[a-z]*_news_daily_*.html") if _match_file(p, date_str, report_type))
+ stats.scanned = len(files)
+ logger.info("扫描到日报文件 {} 份: {}", stats.scanned, root)
+
+ conn = connect()
+ try:
+ for path in files:
+ try:
+ html = path.read_text(encoding="utf-8")
+ report = parse_report(html, path.name)
+ except ReportParseError as e:
+ stats.failed += 1
+ stats.errors.append(f"{path.name}: {e}")
+ logger.warning("解析失败: {} ({})", path.name, e)
+ continue
+ except Exception as e: # 防御未知异常,不中断批量
+ stats.failed += 1
+ stats.errors.append(f"{path.name}: {type(e).__name__}: {e}")
+ logger.exception("读取/解析异常: {}", path.name)
+ continue
+
+ if not force and exists_report(conn, report.report_date, report.report_type, report.file_name):
+ stats.skipped += 1
+ logger.debug("已存在, 跳过: {}", path.name)
+ continue
+ save_report(conn, report)
+ stats.imported += 1
+ finally:
+ conn.close()
+
+ logger.info("导入完成: scanned={} imported={} skipped={} failed={}",
+ stats.scanned, stats.imported, stats.skipped, stats.failed)
+ return stats
diff --git a/report_import/parser.py b/report_import/parser.py
new file mode 100644
index 0000000..a50bb48
--- /dev/null
+++ b/report_import/parser.py
@@ -0,0 +1,298 @@
+"""历史日报 HTML 解析器(finance / intl)。
+
+策略:表头驱动列映射,不依赖列位置;板块按 h2 标题识别;
+数据总览按 h3 标题归类为 stats JSON 快照(前端自行解析)。
+"""
+
+from __future__ import annotations
+
+import re
+from datetime import date, datetime
+
+from bs4 import BeautifulSoup, Tag
+
+from report_db.models import EventRow, ReportData
+
+# 情绪图标 → sentiment 值
+_SENTIMENT_ICON: dict[str, str] = {"⚪": "neutral", "🔴": "negative", "🟢": "positive"}
+
+# 数据总览 h3 标题关键词 → stats key
+_STATS_SECTION_KEYS: list[tuple[str, str]] = [
+ ("管道", "pipeline"),
+ ("各源", "sources"),
+ ("情绪", "sentiment"),
+ ("重要度", "importance"),
+ ("事件类型", "event_types"),
+ ("来源", "source_dist"),
+]
+
+
+class ReportParseError(Exception):
+ """整份文件解析失败。"""
+
+
+# --------------------------------------------------------------------------- #
+# 文件名 / 时间解析
+# --------------------------------------------------------------------------- #
+
+def _parse_datetime_from_filename(file_name: str) -> tuple[date, datetime] | None:
+ """从文件名解析日报日期与生成时间。
+
+ 支持 `{type}_news_daily_{YYYYMMDD}.html` 与带时间戳的
+ `{type}_news_daily_{YYYYMMDD}_{HHMMSS}.html` / `..._{HHMM}.html`。
+ """
+ m = re.search(r"_daily_(\d{8})(?:_(\d{4})(\d{2})?)?", file_name)
+ if not m:
+ return None
+ day = date(int(m.group(1)[:4]), int(m.group(1)[4:6]), int(m.group(1)[6:8]))
+ hh = mm = ss = 0
+ if m.group(2):
+ hh, mm = int(m.group(2)[:2]), int(m.group(2)[2:4])
+ ss = int(m.group(3) or 0)
+ return day, datetime(day.year, day.month, day.day, hh, mm, ss)
+
+
+def _parse_header_generated_at(soup: BeautifulSoup, fallback: datetime) -> datetime:
+ """从 中"生成于 YYYY-MM-DD HH:MM:SS"解析生成时间。"""
+ p = soup.select_one("header p")
+ if p:
+ m = re.search(r"生成于 (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", p.get_text())
+ if m:
+ return datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
+ return fallback
+
+
+# --------------------------------------------------------------------------- #
+# AI 摘要
+# --------------------------------------------------------------------------- #
+
+def _parse_ai_summary(soup: BeautifulSoup) -> str | None:
+ """AI 摘要:,li 逐行输出。"""
+ div = soup.select_one("div.ai-summary")
+ if div is None:
+ return None
+ items = [li.get_text(strip=True) for li in div.find_all("li") if li.get_text(strip=True)]
+ if items:
+ return "\n".join(items)
+ text = re.sub(r"\s+", " ", div.get_text(strip=True))
+ return text or None
+
+
+# --------------------------------------------------------------------------- #
+# 事件表解析
+# --------------------------------------------------------------------------- #
+
+def _to_int(text: str) -> int | None:
+ m = re.search(r"\d+", text or "")
+ return int(m.group(0)) if m else None
+
+
+def _parse_summary(cell: Tag) -> tuple[str | None, str | None]:
+ """摘要列:剥除 [来源],返回 (摘要, 来源)。"""
+ source = None
+ small = cell.find("small")
+ if small:
+ m = re.search(r"\[([^\]]+)\]", small.get_text())
+ if m:
+ source = m.group(1)
+ small.decompose()
+ text = re.sub(r"\s+", " ", cell.get_text(strip=True))
+ return (text or None, source)
+
+
+def _clean_title(cell: Tag) -> str:
+ """标题列:剥除 股票代码标注等,返回纯标题。"""
+ for small in cell.find_all("small"):
+ small.decompose()
+ return re.sub(r"\s+", " ", cell.get_text(strip=True))
+
+
+def _parse_event_table(table: Tag, section: str) -> list[EventRow]:
+ """事件表 → EventRow 列表。表头驱动列映射,兼容 xwlb/news/cninfo/intl 四类表。"""
+ rows = table.find_all("tr")
+ if len(rows) < 2:
+ return []
+ header_cells = rows[0].find_all(["th", "td"])
+ col_index: dict[str, int] = {}
+ icon_col: int | None = None
+ for i, cell in enumerate(header_cells):
+ text = cell.get_text(strip=True)
+ if text:
+ col_index[text] = i
+ elif icon_col is None:
+ icon_col = i
+
+ out: list[EventRow] = []
+ for row in rows[1:]:
+ tds = row.find_all("td")
+ if not tds:
+ continue
+
+ def col(name: str, tds: list[Tag] = tds) -> Tag | None: # noqa: B008 - 绑定循环变量
+ idx = col_index.get(name)
+ return tds[idx] if idx is not None and idx < len(tds) else None
+
+ title_cell = col("标题")
+ if title_cell is None:
+ continue
+ a = title_cell.find("a")
+ url = a.get("href") if a else None
+
+ summary_cell = col("摘要")
+ summary: str | None = None
+ source: str | None = None
+ if summary_cell is not None:
+ summary, source = _parse_summary(summary_cell)
+ if source is None:
+ src_cell = col("源")
+ if src_cell is not None and src_cell.get_text(strip=True):
+ source = src_cell.get_text(strip=True)
+
+ sentiment = None
+ if icon_col is not None and icon_col < len(tds):
+ sentiment = _SENTIMENT_ICON.get(tds[icon_col].get_text(strip=True))
+
+ imp_cell = col("重要度")
+ type_cell = col("事件类型")
+ out.append(
+ EventRow(
+ section=section,
+ rank=_to_int(tds[0].get_text(strip=True)) or len(out) + 1,
+ importance=_to_int(imp_cell.get_text(strip=True)) if imp_cell else None,
+ event_type=type_cell.get_text(strip=True) if type_cell else None,
+ title=_clean_title(title_cell),
+ summary=summary,
+ sentiment=sentiment,
+ source=source,
+ url=url,
+ )
+ )
+ return out
+
+
+def _collect_events(soup: BeautifulSoup, report_type: str) -> list[EventRow]:
+ """按 h2 板块标题收集各事件表。"""
+ events: list[EventRow] = []
+ for h2 in soup.find_all("h2"):
+ title = h2.get_text()
+ table = h2.find_next_sibling("table")
+ if table is None:
+ continue
+ section: str | None = None
+ if "新闻联播" in title:
+ section = "xwlb"
+ elif "公告" in title or "调研" in title:
+ section = "cninfo"
+ elif "重要事件" in title:
+ section = "intl" if report_type == "intl" else "news"
+ if section is not None:
+ events.extend(_parse_event_table(table, section))
+ return events
+
+
+# --------------------------------------------------------------------------- #
+# 数据总览 stats
+# --------------------------------------------------------------------------- #
+
+def _table_to_rows(table: Tag) -> list[dict[str, str]]:
+ """表格 → [{表头: 值, ...}, ...](首行为表头)。"""
+ rows: list[list[str]] = []
+ for tr in table.find_all("tr"):
+ cells = [re.sub(r"\s+", " ", c.get_text(strip=True)) for c in tr.find_all(["th", "td"])]
+ if cells:
+ rows.append(cells)
+ if not rows:
+ return []
+ header = rows[0]
+ return [dict(zip(header, r, strict=False)) for r in rows[1:]]
+
+
+def _parse_stats_block(h3: Tag) -> tuple[str, object] | None:
+ """h3 数据总览区块 → (stats_key, value)。缺失/未知板块返回 None。"""
+ title = h3.get_text()
+ key = next((k for kw, k in _STATS_SECTION_KEYS if kw in title), None)
+ if key is None:
+ return None
+ block = h3.find_next_sibling()
+ if block is None:
+ return key, {}
+
+ if block.name == "table":
+ return key, _table_to_rows(block)
+
+ classes = block.get("class", []) if isinstance(block.get("class"), list) else []
+ if "stats-grid" in classes:
+ cards: dict[str, str | int] = {}
+ for card in block.find_all("div", class_="stat-card"):
+ label = card.select_one(".label")
+ num = card.select_one(".num")
+ if label is not None:
+ num_text = num.get_text(strip=True) if num else ""
+ cards[label.get_text(strip=True)] = _to_int(num_text) if _to_int(num_text) is not None else num_text
+ return key, cards
+ if "source-grid" in classes:
+ items: dict[str, str | int] = {}
+ for item in block.find_all("div", class_="source-item"):
+ name = item.select_one(".s-name")
+ count = item.select_one(".s-count")
+ if name is not None:
+ count_text = count.get_text(strip=True) if count else ""
+ items[name.get_text(strip=True)] = _to_int(count_text) or count_text
+ return key, items
+ if "sentiment-bar" in classes:
+ legend = block.find_next_sibling("div", class_="sentiment-legend")
+ spans = legend.find_all("span") if legend else []
+ return key, [re.sub(r"\s+", " ", s.get_text(strip=True)) for s in spans]
+
+ text = re.sub(r"\s+", " ", block.get_text(strip=True))
+ return key, text[:500]
+
+
+def _collect_stats(soup: BeautifulSoup) -> dict[str, object]:
+ stats: dict[str, object] = {}
+ for h3 in soup.find_all("h3"):
+ parsed = _parse_stats_block(h3)
+ if parsed is not None:
+ stats[parsed[0]] = parsed[1]
+ return stats
+
+
+# --------------------------------------------------------------------------- #
+# 主入口
+# --------------------------------------------------------------------------- #
+
+def _parse(html: str, file_name: str, report_type: str) -> ReportData:
+ parsed = _parse_datetime_from_filename(file_name)
+ if parsed is None:
+ raise ReportParseError(f"无法从文件名解析日报日期: {file_name}")
+ day, gen_from_file = parsed
+
+ soup = BeautifulSoup(html, "html.parser")
+ generated_at = _parse_header_generated_at(soup, gen_from_file)
+
+ return ReportData(
+ report_date=day,
+ report_type=report_type,
+ file_name=file_name,
+ generated_at=generated_at,
+ ai_summary=_parse_ai_summary(soup),
+ stats=_collect_stats(soup),
+ events=_collect_events(soup, report_type),
+ )
+
+
+def parse_finance_report(html: str, file_name: str) -> ReportData:
+ """解析 A 股日报 finance_news_daily_*.html。"""
+ return _parse(html, file_name, "finance")
+
+
+def parse_intl_report(html: str, file_name: str) -> ReportData:
+ """解析国际财经日报 intl_news_daily_*.html。"""
+ return _parse(html, file_name, "intl")
+
+
+def parse_report(html: str, file_name: str) -> ReportData:
+ """按文件名前缀自动分流 finance / intl。"""
+ if "intl_news_daily" in file_name:
+ return parse_intl_report(html, file_name)
+ return parse_finance_report(html, file_name)
diff --git a/scheduler/reporter.py b/scheduler/reporter.py
index 04e731e..26b3af9 100644
--- a/scheduler/reporter.py
+++ b/scheduler/reporter.py
@@ -21,6 +21,8 @@ from typing import Any
from dotenv import load_dotenv
from loguru import logger
+from report_db.models import EventRow, ReportData # noqa: F401 - 供 _build_report_data 注解使用
+
# 确保 .env 已加载(模块级常量依赖环境变量)
load_dotenv()
@@ -843,7 +845,7 @@ def _render_xwlb_section(xwlb: dict | None) -> str:
def _render_html(news: dict, cninfo: dict, pipeline: dict,
ai_summary: str, day_str: str,
xwlb: dict | None = None) -> str:
- """组装完整 HTML。"""
+ """组装完整 HTML(M10 起废弃:日报已改为结构化入库,此函数不再被调用,保留以便回退)。"""
# AI 摘要 → HTML
summary_html = _re.sub(r"\*\*(.+?)\*\*", r"\1", ai_summary)
@@ -941,8 +943,76 @@ def _render_html(news: dict, cninfo: dict, pipeline: dict,
# 生成 + 上传
# --------------------------------------------------------------------------- #
-def generate_report(day_str: str | None = None, *, upload: bool = True) -> Path | None:
- """生成每日摘要 HTML 报告,可选上传到 Web 服务器。"""
+def _build_report_data(news: dict, cninfo: dict, pipeline: dict,
+ ai_summary: str, day_str: str,
+ xwlb: dict | None = None) -> ReportData:
+ """组装结构化日报数据(M10:写入 MySQL 的前置步骤)。
+
+ 事件板块映射:news["high"]→news / cninfo["high"]→cninfo / xwlb["items"]→xwlb。
+ 数据总览统计以 JSON 快照存入 stats(前端自行解析)。
+ """
+ events: list[EventRow] = []
+
+ def _rows(items: list[dict], section: str) -> None:
+ for i, e in enumerate(items, 1):
+ ev = e.get("event", {})
+ events.append(
+ EventRow(
+ section=section,
+ rank=i,
+ importance=ev.get("importance"),
+ event_type=ev.get("event_type"),
+ title=str(e.get("title", ""))[:512],
+ summary=(ev.get("summary") or None),
+ sentiment=ev.get("sentiment") or None,
+ source=e.get("source_id") or None,
+ url=e.get("url") or None,
+ )
+ )
+
+ _rows(news.get("high", []), "news")
+ _rows(cninfo.get("high", []), "cninfo")
+ if xwlb:
+ _rows(xwlb.get("items", []), "xwlb")
+
+ stats: dict[str, Any] = {
+ "pipeline": pipeline,
+ "news": {
+ "total": news.get("total", 0),
+ "hi_threshold": news.get("hi_threshold"),
+ "sentiments": news.get("sentiments", {}),
+ "importances": news.get("importances", {}),
+ "event_types": news.get("event_types", {}),
+ },
+ "cninfo": {
+ "total": cninfo.get("total", 0),
+ "hi_threshold": cninfo.get("hi_threshold"),
+ "by_day": cninfo.get("by_day", {}),
+ "announcement": cninfo.get("announcement", 0),
+ "research": cninfo.get("research", 0),
+ "irm": cninfo.get("irm", 0),
+ },
+ }
+ if xwlb:
+ stats["xwlb"] = {"total": len(xwlb.get("items", [])), "date": xwlb.get("date", "")}
+
+ return ReportData(
+ report_date=datetime.strptime(day_str, "%Y%m%d").date(),
+ report_type="finance",
+ file_name="", # 新生成日报唯一键退化为 (report_date, finance, "")
+ generated_at=datetime.now(),
+ ai_summary=ai_summary or None,
+ stats=stats,
+ events=events,
+ )
+
+
+def generate_report(day_str: str | None = None, *, upload: bool = True) -> int | None:
+ """生成每日日报并结构化入库(M10 完全切换,不再生成 HTML)。
+
+ `upload` 参数保留以兼容 scheduler/pipeline.py 调用,已无实际作用。
+ 返回 report_id(成功)或 None(无数据/失败)。
+ """
day_str = day_str or date.today().strftime("%Y%m%d")
logger.info("生成日报: {}", day_str)
@@ -963,25 +1033,26 @@ def generate_report(day_str: str | None = None, *, upload: bool = True) -> Path
# AI 摘要(新闻联播 + 新闻 + cninfo)
ai_summary = _generate_ai_summary(news, cninfo, day_str, xwlb=xwlb)
- # 渲染
- html = _render_html(news, cninfo, pipeline, ai_summary, day_str, xwlb=xwlb)
+ # 结构化入库(替代原 HTML 渲染 + 上传)
+ report = _build_report_data(news, cninfo, pipeline, ai_summary, day_str, xwlb=xwlb)
+ try:
+ from report_db import connect, save_report
- # 保存(文件名含时间,支持一天多次生成)
- out_dir = Path("data/reports")
- out_dir.mkdir(parents=True, exist_ok=True)
- file_tag = f"{day_str}_{datetime.now():%H%M}"
- html_path = out_dir / f"finance_news_daily_{file_tag}.html"
- html_path.write_text(html, encoding="utf-8")
- logger.info("日报已保存: {} ({} KB)", html_path, len(html) // 1024)
+ conn = connect()
+ try:
+ report_id = save_report(conn, report)
+ finally:
+ conn.close()
+ except Exception as e:
+ logger.exception("日报入库失败: {}", e)
+ return None
- if upload:
- _upload(html_path, file_tag)
-
- return html_path
+ logger.info("日报已入库: report_id={}", report_id)
+ return report_id
def _upload(html_path: Path, file_tag: str) -> bool:
- """上传 HTML 报告到 Web 服务器。"""
+ """上传 HTML 报告到 Web 服务器(M10 起废弃:不再被调用,保留以便回退)。"""
today_str = date.today().strftime("%Y%m%d")
remote_dir = f"{UPLOAD_BASE}/{today_str}/"
logger.info("上传日报到 {}:{}", UPLOAD_HOST, remote_dir)
diff --git a/tests/fixtures/finance_news_daily_20260710_0720.html b/tests/fixtures/finance_news_daily_20260710_0720.html
new file mode 100644
index 0000000..dabb912
--- /dev/null
+++ b/tests/fixtures/finance_news_daily_20260710_0720.html
@@ -0,0 +1,157 @@
+
+
+
+
+
+A 股 Deep Research 日报 — 20260710_0720
+
+
+
+
+
+
+
+一、AI 摘要
+- 国务院印发《十五五碳达峰行动方案》,提出2030年新型储能装机3亿千瓦等目标,为新能源与储能行业确立长期增长路径,是当前最具影响力的政策信号。
- 长鑫科技启动招股拟募资295亿元,成2026年A股最大IPO,带动存储产业链及半导体板块强势上涨,兆易创新业绩预增超1000%印证行业高景气。
- 李强主持召开国务院常务会议部署防汛抗洪救灾工作,强调保障受灾群众生活,利好水利建设、应急物资等板块,体现政策托底效应。
- 近15日重要公告/调研:晶盛机电、盐湖股份获机构调研;高测股份公告开展期货套期保值业务并推进限制性股票激励计划。
- 市场情绪基调:利好。政策利好(碳达峰方案)、IPO巨
+
+
+二、📺 新闻联播 (07月10日, 共 16 条, 按重要度排序)
+| # | | 标题 | 重要度 | 事件类型 |
|---|
| 1 | ⚪ | 张国清赴广西指导支持受灾群众生活保障和灾后恢复工作 | 4 | 新闻联播 | | 2 | ⚪ | 李强主持召开国务院常务会议 部署防汛抗洪救灾等工作 | 4 | 新闻联播 | | 3 | ⚪ | 习近平会见朝鲜内阁总理朴泰成 | 3 | 新闻联播 | | 4 | ⚪ | 人民日报将发表评论员文章:加快推进高水平科技自立自强 | 3 | 新闻联播 | | 5 | ⚪ | 赵乐际会见朝鲜内阁总理朴泰成 | 3 | 新闻联播 | | 6 | ⚪ | 赵乐际会见纳米比亚总统恩戴特瓦 | 3 | 新闻联播 | | 7 | ⚪ | 中央层面整治形式主义为基层减负专项工作机制会议在京召开 | 2 | 新闻联播 | | 8 | ⚪ | 全国用电负荷创历史新高 达15.18亿千瓦 | 2 | 新闻联播 | | 9 | ⚪ | 我国夏粮丰收 产量首次突破3000亿斤 | 2 | 新闻联播 | | 10 | ⚪ | 暑运启动以来全国铁路发送旅客超1.23亿人次 | 2 | 新闻联播 | | 11 | ⚪ | 2026年中国航海日上海主题活动启动 | 1 | 新闻联播 | | 12 | ⚪ | 上半年全国口岸出入境人员3.69亿人次 创历史新高 | 1 | 新闻联播 | | 13 | ⚪ | 伊朗媒体称布什尔核电站遭美军袭击 美伊仍进行核谈判 | 1 | 新闻联播 | | 14 | ⚪ | 俄称红利曼战斗进入收尾阶段 乌称打击俄海上目标 | 1 | 新闻联播 | | 15 | ⚪ | 特朗普称将向乌克兰发放爱国者生产许可 俄方回应 | 1 | 新闻联播 | | 16 | ⚪ | 长征十号乙运载火箭完成全球首次海上网系回收 | 1 | 新闻联播 |
+
+ 来源: 央视《新闻联播》· 数据取自 doorcome API (xwlbFine) · 20260710
+
+
+
+三、🔥 重要事件:新闻 (0/758 篇 24h 内, importance ≥ 4, 共 20 篇)
+
+
+
+四、📋 重要事件:公告 / 调研 / 互动 (近 15 日, importance ≥ 2, 共 20 篇)
+
+
+
+五、数据总览
+
+5.1 M1 → M6 管道
+
+
+5.2 各源数据 (0/758 篇 24h 内)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+5.3 情绪分布 (最新抓取)
+🟢 利好 161 (27%)🔴 利空 45 (8%)⚪ 中性 388 (65%)
+
+5.4 重要度分布
+
+ | 重要度 | 等级 1 | 等级 2 | 等级 3 | 等级 4 | 等级 5 |
+ | 数量 | 116 | 184 | 167 | 90 | 37 |
+
+
+5.5 事件类型分布
+
+ | 事件类型 | 数量 |
+ | 其他 | 211 |
+| 国际局势 | 107 |
+| 宏观政策 | 80 |
+| 行业政策 | 53 |
+| 业绩预告 | 35 |
+| 技术突破 | 29 |
+| 产品发布 | 17 |
+| 监管处罚 | 16 |
+| 投资并购 | 14 |
+| 合作签约 | 7 |
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/intl_news_daily_20260711_070304.html b/tests/fixtures/intl_news_daily_20260711_070304.html
new file mode 100644
index 0000000..66c4840
--- /dev/null
+++ b/tests/fixtures/intl_news_daily_20260711_070304.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+国际财经 Deep Research 日报 — 20260711_070304
+
+
+
+
+
+
+
+一、🤖 AI 摘要
+
+- 美伊冲突升级与霍尔木兹海峡危机:美国空袭伊朗并遭报复,伊朗利用海峡控制权威胁全球石油供应,油价持续飙升,能源成本上升加剧通胀担忧,利空风险资产但利好能源股,是当日影响最大的地缘政治事件。
+- 日本央行政策转向:日本政府协调养老金增持国内资产并强化加息预期,推动日元显著升值,利好日本金融市场,反映全球货币政策分化加剧。
+- 欧洲央行加息预期与通胀缓解:9月加息已完全定价,德法通胀符合预期,欧元区通胀压力整体缓解,市场影响中性,但后续加息路径需关注。
+- 市场情绪基调:整体偏利空——地缘政治风险主导,油价压制股市情绪;分化明显(能源股利好,其他风险资产承压);日本市场因政策利好相对独立;加密行业受监管预期小幅提振。
+- 值得持续关注的行业与主题:AI行业成本压力与投资分化(Palo Alto要求降价90%,亚马逊、SK海力士加码基础设施);稳定币及加密监管进展(Circle获准运营信托银行);霍尔木兹海峡局势演变;全球通胀与央行政策路径。
+
+
+
+二、🔥 重要事件 (importance ≥ 4, 19 条)
+
+
+
+三、📊 数据总览
+
+3.1 M1→M6 管道
+
+
+3.2 情绪分布 (当日事件)
+🟢 利好 13 (18%)🔴 利空 18 (24%)⚪ 中性 43 (58%)
+
+3.3 重要度分布
+
+
+3.4 事件类型 TOP 10
+| 事件类型 | 数量 |
|---|
| 宏观经济 | 17 | | 地缘政治 | 14 | | 行业动态 | 10 | | 市场异动 | 7 | | 央行决议 | 7 | | 大宗商品 | 4 | | 外汇波动 | 4 | | 监管政策 | 4 | | 财报披露 | 4 | | 技术突破 | 1 |
+
+3.5 文章来源分布
+| 来源 | 文章数 |
|---|
| ForexLive | 44 | | ZeroHedge | 3 | | CNBC | 2 | | Yahoo Finance | 2 | | Seeking Alpha | 1 |
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py
new file mode 100644
index 0000000..e05b35e
--- /dev/null
+++ b/tests/test_report_builder.py
@@ -0,0 +1,87 @@
+"""日报结构化组装单元测试(_build_report_data,纯逻辑)。"""
+
+from __future__ import annotations
+
+import json
+from datetime import date
+
+from scheduler.reporter import _build_report_data
+
+
+def _fake_event(title: str, importance: int, event_type: str = "其他",
+ sentiment: str = "neutral", source_id: str = "cls",
+ url: str = "https://x.com/1") -> dict:
+ return {
+ "title": title,
+ "url": url,
+ "source_id": source_id,
+ "event": {
+ "stock_codes": [],
+ "company_names": [],
+ "industries": [],
+ "sentiment": sentiment,
+ "importance": importance,
+ "event_type": event_type,
+ "summary": f"{title}的摘要",
+ },
+ }
+
+
+class TestBuildReportData:
+ def test_sections_and_ranks(self) -> None:
+ news = {
+ "total": 2, "hi_threshold": 4,
+ "high": [_fake_event("新闻A", 5), _fake_event("新闻B", 4)],
+ "sentiments": {"neutral": 2}, "importances": {5: 1, 4: 1},
+ "event_types": {"其他": 2},
+ }
+ cninfo = {
+ "total": 1, "hi_threshold": 2,
+ "high": [_fake_event("公告C", 3, event_type="公告")],
+ "by_day": {"07月10日": 1}, "announcement": 1, "research": 0, "irm": 0,
+ }
+ pipeline = {"raw_total": 100, "proc": 90}
+ xwlb = {"items": [_fake_event("联播D", 4, event_type="新闻联播", source_id="xwlb")],
+ "date": "07月10日"}
+
+ r = _build_report_data(news, cninfo, pipeline, "AI摘要", "20260710", xwlb=xwlb)
+
+ assert r.report_date == date(2026, 7, 10)
+ assert r.report_type == "finance"
+ assert r.file_name == ""
+ assert r.ai_summary == "AI摘要"
+ assert [(e.section, e.rank) for e in r.events] == [
+ ("news", 1), ("news", 2), ("cninfo", 1), ("xwlb", 1),
+ ]
+ assert r.events[0].source == "cls"
+ assert r.events[3].source == "xwlb"
+
+ def test_stats_snapshot(self) -> None:
+ news = {"total": 1, "hi_threshold": 4, "high": [], "sentiments": {},
+ "importances": {}, "event_types": {}}
+ cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
+ "announcement": 0, "research": 0, "irm": 0}
+ r = _build_report_data(news, cninfo, {"raw_total": 100}, "s", "20260710")
+ # stats 可 JSON 序列化(入库时 json.dumps)
+ json.dumps(r.stats, ensure_ascii=False)
+ assert r.stats["pipeline"] == {"raw_total": 100}
+ assert r.stats["news"]["total"] == 1
+ assert "xwlb" not in r.stats
+
+ def test_title_truncated(self) -> None:
+ news = {"total": 1, "hi_threshold": 4,
+ "high": [_fake_event("长" * 600, 4)], "sentiments": {},
+ "importances": {}, "event_types": {}}
+ cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
+ "announcement": 0, "research": 0, "irm": 0}
+ r = _build_report_data(news, cninfo, {}, "s", "20260710")
+ assert len(r.events[0].title) == 512
+
+ def test_empty_events(self) -> None:
+ news = {"total": 0, "hi_threshold": 0, "high": [], "sentiments": {},
+ "importances": {}, "event_types": {}}
+ cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
+ "announcement": 0, "research": 0, "irm": 0}
+ r = _build_report_data(news, cninfo, {}, None, "20260710")
+ assert r.events == []
+ assert r.ai_summary is None
diff --git a/tests/test_report_db.py b/tests/test_report_db.py
new file mode 100644
index 0000000..b13cda8
--- /dev/null
+++ b/tests/test_report_db.py
@@ -0,0 +1,51 @@
+"""日报数据模型单元测试。"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+
+import pytest
+from pydantic import ValidationError
+
+from report_db.models import EventRow, ReportData
+
+
+class TestEventRow:
+ def test_minimal(self) -> None:
+ ev = EventRow(section="news", rank=1, title="标题")
+ assert ev.importance is None
+ assert ev.sentiment is None
+
+ def test_full(self) -> None:
+ ev = EventRow(
+ section="intl", rank=2, importance=4, event_type="地缘政治",
+ title="t", summary="s", sentiment="negative", source="ForexLive",
+ url="https://x.com/1",
+ )
+ assert ev.sentiment == "negative"
+
+ def test_missing_title_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ EventRow(section="news", rank=1) # type: ignore[call-arg]
+
+
+class TestReportData:
+ def test_defaults(self) -> None:
+ r = ReportData(
+ report_date=date(2026, 7, 11),
+ report_type="finance",
+ generated_at=datetime(2026, 7, 11, 7, 0),
+ )
+ assert r.file_name == ""
+ assert r.stats == {}
+ assert r.events == []
+
+ def test_with_events(self) -> None:
+ r = ReportData(
+ report_date=date(2026, 7, 11),
+ report_type="finance",
+ generated_at=datetime(2026, 7, 11, 7, 0),
+ ai_summary="摘要",
+ events=[EventRow(section="xwlb", rank=1, title="t")],
+ )
+ assert len(r.events) == 1
diff --git a/tests/test_report_import.py b/tests/test_report_import.py
new file mode 100644
index 0000000..f6660ac
--- /dev/null
+++ b/tests/test_report_import.py
@@ -0,0 +1,32 @@
+"""历史导入器单元测试(文件匹配/扫描逻辑,不依赖真实 DB)。"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from report_import.importer import _match_file
+
+
+class TestMatchFile:
+ def test_finance(self) -> None:
+ p = Path("20260711/finance_news_daily_20260710_0720.html")
+ assert _match_file(p, None, None)
+ assert _match_file(p, "20260710", None)
+ assert _match_file(p, None, "finance")
+ assert not _match_file(p, "20260711", None) # 文件名日期不含 20260711
+ assert not _match_file(p, None, "intl")
+
+ def test_no_timestamp_suffix(self) -> None:
+ # 早期文件无时间戳后缀,也应匹配
+ p = Path("20260616/finance_news_daily_20260616.html")
+ assert _match_file(p, None, None)
+ assert _match_file(p, "20260616", "finance")
+
+ def test_intl(self) -> None:
+ p = Path("20260711/intl_news_daily_20260711_070304.html")
+ assert _match_file(p, "20260711", "intl")
+ assert not _match_file(p, None, "finance")
+
+ def test_non_report_ignored(self) -> None:
+ assert not _match_file(Path("20260711/002714.SZ_0724.html"), None, None)
+ assert not _match_file(Path("20260711/readme.md"), None, None)
diff --git a/tests/test_report_parser.py b/tests/test_report_parser.py
new file mode 100644
index 0000000..e94f99b
--- /dev/null
+++ b/tests/test_report_parser.py
@@ -0,0 +1,98 @@
+"""历史日报解析器单元测试(基于真实样例 HTML)。"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+from pathlib import Path
+
+import pytest
+
+from report_import.parser import (
+ ReportParseError,
+ parse_finance_report,
+ parse_intl_report,
+ parse_report,
+)
+
+FIXTURES = Path(__file__).parent / "fixtures"
+FINANCE_HTML = (FIXTURES / "finance_news_daily_20260710_0720.html").read_text(encoding="utf-8")
+INTL_HTML = (FIXTURES / "intl_news_daily_20260711_070304.html").read_text(encoding="utf-8")
+
+
+class TestFinanceParse:
+ def test_metadata(self) -> None:
+ r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
+ assert r.report_date == date(2026, 7, 10)
+ assert r.report_type == "finance"
+ assert r.file_name == "finance_news_daily_20260710_0720.html"
+ assert r.generated_at == datetime(2026, 7, 11, 7, 20, 27) # header"生成于"优先
+
+ def test_ai_summary_lines(self) -> None:
+ r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
+ assert r.ai_summary is not None
+ assert len(r.ai_summary.splitlines()) >= 5
+ assert "碳达峰" in r.ai_summary
+
+ def test_sections_and_ranks(self) -> None:
+ r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
+ sections = {e.section for e in r.events}
+ assert sections == {"xwlb", "news", "cninfo"}
+ assert sum(1 for e in r.events if e.section == "xwlb") == 16
+ assert sum(1 for e in r.events if e.section == "news") == 20
+ assert sum(1 for e in r.events if e.section == "cninfo") == 20
+
+ def test_event_fields(self) -> None:
+ r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
+ xwlb = next(e for e in r.events if e.section == "xwlb")
+ assert xwlb.importance == 4
+ assert xwlb.event_type == "新闻联播"
+ assert xwlb.sentiment == "neutral"
+ assert "张国清" in xwlb.title
+ news = next(e for e in r.events if e.section == "news" and e.source)
+ assert news.source # 新闻板块带来源
+
+ def test_stats_keys(self) -> None:
+ r = parse_finance_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
+ for key in ("pipeline", "sources", "sentiment", "importance", "event_types"):
+ assert key in r.stats, f"缺少 stats.{key}"
+ assert r.stats["pipeline"]["M1 原始文章"] == 758
+
+
+class TestIntlParse:
+ def test_metadata(self) -> None:
+ r = parse_intl_report(INTL_HTML, "intl_news_daily_20260711_070304.html")
+ assert r.report_date == date(2026, 7, 11)
+ assert r.report_type == "intl"
+ assert r.generated_at == datetime(2026, 7, 11, 7, 3, 30)
+
+ def test_events_and_source_from_small(self) -> None:
+ r = parse_intl_report(INTL_HTML, "intl_news_daily_20260711_070304.html")
+ assert len(r.events) == 19
+ first = r.events[0]
+ assert first.section == "intl"
+ assert first.source == "investinglive.com" # 从摘要 [来源] 提取
+ assert first.url.startswith("https://investinglive.com/")
+ assert first.importance == 4
+ assert first.event_type == "地缘政治"
+ # 摘要中不应残留 [来源] 标记
+ assert first.summary is not None and "[investinglive.com]" not in first.summary
+
+ def test_stats_keys(self) -> None:
+ r = parse_intl_report(INTL_HTML, "intl_news_daily_20260711_070304.html")
+ for key in ("pipeline", "sentiment", "importance", "event_types", "source_dist"):
+ assert key in r.stats
+ assert r.stats["source_dist"][0]["来源"] == "ForexLive"
+
+
+class TestParseReportDispatch:
+ def test_dispatch_finance(self) -> None:
+ r = parse_report(FINANCE_HTML, "finance_news_daily_20260710_0720.html")
+ assert r.report_type == "finance"
+
+ def test_dispatch_intl(self) -> None:
+ r = parse_report(INTL_HTML, "intl_news_daily_20260711_070304.html")
+ assert r.report_type == "intl"
+
+ def test_bad_filename_raises(self) -> None:
+ with pytest.raises(ReportParseError):
+ parse_report("", "not_a_report.html")
diff --git a/uv.lock b/uv.lock
index 67c5c23..a9690c1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -23,6 +23,7 @@ dependencies = [
{ name = "numpy" },
{ name = "openai" },
{ name = "pydantic" },
+ { name = "pymysql" },
{ name = "pypdf" },
{ name = "python-dateutil" },
{ name = "python-dotenv" },
@@ -60,6 +61,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=1.26" },
{ name = "openai", specifier = ">=1.40" },
{ name = "pydantic", specifier = ">=2.7" },
+ { name = "pymysql", specifier = ">=1.2.0" },
{ name = "pypdf", specifier = ">=6.13.3" },
{ name = "python-dateutil", specifier = ">=2.9" },
{ name = "python-dotenv", specifier = ">=1.0" },
@@ -1983,6 +1985,15 @@ crypto = [
{ name = "cryptography" },
]
+[[package]]
+name = "pymysql"
+version = "1.2.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/bc/1c6a92f385940f727daeecf3bacaf186e03875dff57197801046c583bcf0/pymysql-1.2.0.tar.gz", hash = "sha256:6c7b17ca686988104d7426c27895b455cdeea3e9d3ceb1270f0c3704fead8c33" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/c4/bd/2534e130295c8cfd4f0a2e31623baab7502278f1e97bcfe61db75656a77f/pymysql-1.2.0-py3-none-any.whl", hash = "sha256:62169ce6d5510f08e140c5e7990ee884a9764024e4a9a27b2cc11f1099322ae0" },
+]
+
[[package]]
name = "pyopenssl"
version = "26.3.0"
|