feat: 日报结构化入库(M10 前后端分离数据层)

- 新增 report_db 包: MySQL 连接/建表/幂等写入 (news_report/news_event, myquant 库)
- 新增 report_import 包: 历史 178 份日报 HTML 解析入库, 表头驱动列映射
- reporter.py 完全切换: generate_report 结构化入库, 不再生成/上传 HTML
- CLI: 新增 report-import 子命令
- 依赖: uv add pymysql; 配置: NEWS_DB_* / REPORT_HISTORY_DIR
- 文档: docs/report_db_design.md(实现逻辑), docs/db_schema.md(表结构供 API/前端)
- 测试: 24 个单测通过 (parser/builder/models/importer)
This commit is contained in:
2026-08-03 21:32:07 +08:00
parent f2c80c5a9c
commit 366e60e8a9
25 changed files with 1934 additions and 33 deletions
+21
View File
@@ -0,0 +1,21 @@
"""历史日报解析与批量导入(Milestone 10)。
将 doorcome 历史日报 HTMLfinance/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",
]
+89
View File
@@ -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
+298
View File
@@ -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:
"""从 <header> 中"生成于 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 摘要:<div class="ai-summary">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]:
"""摘要列:剥除 <small>[来源]</small>,返回 (摘要, 来源)。"""
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:
"""标题列:剥除 <small> 股票代码标注等,返回纯标题。"""
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)