- 新增 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)
1080 lines
40 KiB
Python
1080 lines
40 KiB
Python
"""每日摘要报告生成器 v2.0。
|
||
|
||
输出 HTML 日报,包含:
|
||
一、AI 摘要 (最新新闻 + {cninfo_days}d 公告/调研/互动)
|
||
二、重要事件: 新闻 (最新抓取, importance≥4, 最多20篇)
|
||
三、重要事件: 公告/互动 ({cninfo_days}d, cninfo, 最多20篇)
|
||
四、数据总览 (重要度/各源/M1-M6/情绪/事件类型分布)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os as _os
|
||
import re as _re
|
||
import subprocess
|
||
from collections import Counter
|
||
from datetime import date, datetime, timedelta
|
||
from pathlib import Path
|
||
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()
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 配置
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
UPLOAD_HOST = "simon@doorcome.cn"
|
||
UPLOAD_BASE = "/var/www/html/echart/research"
|
||
|
||
CNINFO_DAYS_BACK = int(_os.environ.get("STOCK_REPORT_DAYS", "15")) # 与个股日报共用参数, 默认值保持一致
|
||
NEWS_DAYS_BACK = 1 # 新闻回溯天数
|
||
|
||
_MAX_HIGH_EVENTS = 20
|
||
|
||
|
||
def _load_source_names() -> dict[str, str]:
|
||
import yaml
|
||
try:
|
||
with open("configs/sources.yaml", encoding="utf-8") as f:
|
||
data = yaml.safe_load(f)
|
||
return {s["id"]: s["name"] for s in (data.get("sources") or []) if s.get("id")}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _source_name(src_id: str) -> str:
|
||
return _load_source_names().get(src_id, src_id)
|
||
|
||
|
||
def _load_watchlist_codes() -> set[str]:
|
||
import yaml
|
||
try:
|
||
with open("configs/watchlist.yaml", encoding="utf-8") as f:
|
||
data = yaml.safe_load(f) or {}
|
||
return {it["code"] for it in (data.get("watchlist") or []) if it.get("code")}
|
||
except Exception:
|
||
return set()
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 数据收集
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def _count_jsonl(path: Path) -> int:
|
||
if not path.is_file():
|
||
return 0
|
||
return sum(1 for _ in open(path, encoding="utf-8"))
|
||
|
||
|
||
def _count_json(pattern: str) -> int:
|
||
return len(list(Path().glob(pattern)))
|
||
|
||
|
||
def _load_events_from_dir(day_str: str) -> list[dict]:
|
||
"""从 data/events/{day_str}/ 加载所有事件。"""
|
||
events: list[dict] = []
|
||
ev_dir = Path(f"data/events/{day_str}")
|
||
if not ev_dir.is_dir():
|
||
return events
|
||
for fp in sorted(ev_dir.glob("*.json")):
|
||
try:
|
||
obj = json.loads(fp.read_text(encoding="utf-8"))
|
||
ev = obj.get("event", {})
|
||
events.append({
|
||
"title": obj.get("title", ""),
|
||
"url": obj.get("url", ""),
|
||
"source_id": obj.get("source_id", ""),
|
||
"publish_time": obj.get("publish_time"),
|
||
"event": ev,
|
||
})
|
||
except (json.JSONDecodeError, OSError):
|
||
pass
|
||
return events
|
||
|
||
|
||
def _collect_news_events(day_str: str) -> dict[str, Any]:
|
||
"""收集新闻事件(排除 cninfo)。
|
||
|
||
事件已按日期目录组织(data/events/{day_str}/),
|
||
不再用 datetime.now() 做 24h 二次过滤,
|
||
避免日报早上 8 点跑时前一天新闻被全部过滤掉。
|
||
"""
|
||
all_ev = _load_events_from_dir(day_str)
|
||
|
||
news_ev: list[dict] = []
|
||
for e in all_ev:
|
||
if e["source_id"] == "cninfo":
|
||
continue
|
||
news_ev.append(e)
|
||
|
||
sentiments: Counter = Counter()
|
||
importances: Counter = Counter()
|
||
event_types: Counter = Counter()
|
||
for e in news_ev:
|
||
ev = e["event"]
|
||
sentiments[ev.get("sentiment", "?")] += 1
|
||
importances[ev.get("importance", 0)] += 1
|
||
event_types[ev.get("event_type", "?")] += 1
|
||
|
||
# 高重要度: 优先 ≥4, 不足时逐级回退(≥3 → ≥2 → 全部按 importance 排序)
|
||
def _get_high(evs, threshold):
|
||
return sorted(
|
||
[e for e in evs if e["event"].get("importance", 0) >= threshold],
|
||
key=lambda e: -e["event"].get("importance", 0),
|
||
)
|
||
|
||
min_show = 3
|
||
high = _get_high(news_ev, 4)
|
||
hi_threshold = 4
|
||
if len(high) < min_show:
|
||
high = _get_high(news_ev, 3)
|
||
hi_threshold = 3
|
||
if len(high) < min_show:
|
||
high = _get_high(news_ev, 2)
|
||
hi_threshold = 2
|
||
if len(high) < min_show:
|
||
high = sorted(news_ev, key=lambda e: -e["event"].get("importance", 0))
|
||
hi_threshold = 0
|
||
high = high[:_MAX_HIGH_EVENTS]
|
||
|
||
return {
|
||
"total": len(news_ev),
|
||
"high": high,
|
||
"hi_threshold": hi_threshold,
|
||
"sentiments": dict(sentiments),
|
||
"importances": dict(sorted(importances.items())),
|
||
"event_types": dict(event_types.most_common(10)),
|
||
}
|
||
|
||
|
||
def _collect_cninfo_events(today_str: str, days_back: int = CNINFO_DAYS_BACK) -> dict[str, Any]:
|
||
"""收集近 N 日 cninfo 公告/调研/互动(直接从 processed 数据读取,不依赖 M4 事件抽取)。
|
||
|
||
cninfo 公告/调研数据已结构化(stock_code/name/title/time/type),
|
||
无需经过 LLM 事件抽取即可直接用于日报。
|
||
"""
|
||
today = datetime.strptime(today_str, "%Y%m%d")
|
||
since_str = (today - timedelta(days=days_back)).strftime("%Y-%m-%d")
|
||
wl_codes = _load_watchlist_codes()
|
||
|
||
items: list[dict] = []
|
||
seen_urls: set[str] = set()
|
||
proc_root = Path("data/processed/cninfo")
|
||
if not proc_root.is_dir():
|
||
return {"total": 0, "high": [], "hi_threshold": 4, "by_day": {},
|
||
"announcement": 0, "research": 0, "irm": 0}
|
||
|
||
for day_dir in sorted(proc_root.glob("*"), reverse=True):
|
||
if not day_dir.is_dir():
|
||
continue
|
||
for fp in sorted(day_dir.glob("*.json"), reverse=True):
|
||
if fp.name == "index.jsonl":
|
||
continue
|
||
try:
|
||
obj = json.loads(fp.read_text(encoding="utf-8"))
|
||
except (json.JSONDecodeError, OSError):
|
||
continue
|
||
|
||
url = obj.get("url") or ""
|
||
if url in seen_urls:
|
||
continue
|
||
seen_urls.add(url)
|
||
|
||
pt = (obj.get("publish_time") or "").strip()
|
||
# 按 publish_time 过滤
|
||
if pt and pt[:10] < since_str:
|
||
continue
|
||
|
||
item_type = obj.get("item_type") or "announcement"
|
||
# 互动易数据跳过(当前无法获取真实数据)
|
||
if item_type == "irm":
|
||
continue
|
||
# 过滤旧数据的 IRM 假阳性(标题为通用占位符或 URL 为 irm 搜索页)
|
||
if "互动问答" in (obj.get("title") or ""):
|
||
continue
|
||
if "irm.cninfo.com.cn" in (obj.get("url") or ""):
|
||
continue
|
||
|
||
title = obj.get("title") or ""
|
||
url = obj.get("url") or ""
|
||
stock_name = obj.get("author") or ""
|
||
content = obj.get("content") or ""
|
||
|
||
# 计算重要度(基于是否在 watchlist 中 + 内容长度)
|
||
code_in_title = ""
|
||
for c in wl_codes:
|
||
if c in title:
|
||
code_in_title = c
|
||
break
|
||
importance = 3 if code_in_title else 2
|
||
if item_type == "research":
|
||
importance = 3 # 调研通常更重要
|
||
|
||
items.append({
|
||
"title": title,
|
||
"url": url,
|
||
"source_id": "cninfo",
|
||
"publish_time": pt,
|
||
"event": {
|
||
"stock_codes": [code_in_title] if code_in_title else [],
|
||
"company_names": [stock_name] if stock_name else [],
|
||
"industries": [],
|
||
"sentiment": "neutral",
|
||
"importance": importance,
|
||
"event_type": {
|
||
"announcement": "公司公告",
|
||
"research": "投资者调研",
|
||
}.get(item_type, "公告"),
|
||
"summary": content[:120] if content else title[:120],
|
||
},
|
||
})
|
||
|
||
# 按发布时间排序(最新在前)
|
||
items.sort(key=lambda e: e.get("publish_time") or "", reverse=True)
|
||
|
||
# 按发布时间的日期分组统计
|
||
by_day: Counter = Counter()
|
||
for e in items:
|
||
pt = (e.get("publish_time") or "")[:10]
|
||
if pt:
|
||
by_day[pt] += 1
|
||
|
||
# 高重要度: 优先 ≥4(调研),逐级回退
|
||
def _get_high(evs, threshold):
|
||
return sorted(
|
||
[e for e in evs if e["event"].get("importance", 0) >= threshold],
|
||
key=lambda e: -e["event"].get("importance", 0),
|
||
)
|
||
min_show = 3
|
||
high = _get_high(items, 4)
|
||
hi_threshold = 4
|
||
if len(high) < min_show:
|
||
high = _get_high(items, 2)
|
||
hi_threshold = 2
|
||
if len(high) < min_show:
|
||
high = sorted(items, key=lambda e: -e["event"].get("importance", 0))
|
||
hi_threshold = 0
|
||
high = high[:_MAX_HIGH_EVENTS]
|
||
|
||
return {
|
||
"total": len(items),
|
||
"high": high,
|
||
"hi_threshold": hi_threshold,
|
||
"by_day": dict(by_day.most_common(7)),
|
||
"announcement": sum(1 for e in items if "公告" in (e["event"].get("event_type", "") or "")),
|
||
"research": sum(1 for e in items if "调研" in (e["event"].get("event_type", "") or "")),
|
||
"irm": sum(1 for e in items if "互动" in (e["event"].get("event_type", "") or "")),
|
||
}
|
||
|
||
|
||
def _score_xwlb_importance(title: str, content: str = "") -> int:
|
||
"""新闻联播条目启发式重要度评分 (1-5)。
|
||
|
||
基于标题+正文关键词匹配,优先匹配高等级:
|
||
5: 直接涉及股市/金融/货币政策
|
||
4: 重大经济/产业政策/能源
|
||
3: 领导人活动/外交/区域发展/外资
|
||
2: 一般国内要闻/农业/生态
|
||
1: 文化/体育/社会/国际简讯
|
||
"""
|
||
text = title + content
|
||
L5 = ["降准", "降息", "印花税", "IPO", "注册制", "退市",
|
||
"并购重组", "增持", "回购", "证券", "股市", "上市"]
|
||
L4 = ["经济", "财政", "税收", "国债", "专项债", "碳",
|
||
"产业", "制造业", "新能源", "芯片", "半导体", "人工智能",
|
||
"算力", "平台经济", "房地产", "外贸", "消费", "投资",
|
||
"供应链", "能源", "电力"]
|
||
L3 = ["习近平", "李强", "总理", "主席", "会谈", "访问",
|
||
"自贸区", "长三角", "粤港澳", "一带一路",
|
||
"央企", "国企", "营商环境", "外资", "达沃斯"]
|
||
L2 = ["会议", "改革", "立法", "监管", "粮食", "农业",
|
||
"水利", "铁路", "公路", "港口", "生态", "救灾"]
|
||
|
||
if any(kw in text for kw in L5):
|
||
return 5
|
||
if any(kw in text for kw in L4):
|
||
return 4
|
||
if any(kw in text for kw in L3):
|
||
return 3
|
||
if any(kw in text for kw in L2):
|
||
return 2
|
||
return 1
|
||
|
||
|
||
def _collect_xwlb(day_str: str) -> dict[str, Any]:
|
||
"""收集新闻联播要闻(从 doorcome API /api/xwlbFine/ 获取)。
|
||
|
||
API 返回 AI 精编后的独立新闻条目(含标题+正文),
|
||
跳过第 1 条"内容提要"(仅为节目开场白)。
|
||
|
||
返回: {"items": [event_dict, ...], "date": "MM月DD日", "source_date": "20260622"}
|
||
"""
|
||
import urllib.request
|
||
|
||
result: dict[str, Any] = {"items": [], "date": "", "source_date": day_str}
|
||
|
||
api_url = f"https://api.doorcome.cn/api/xwlbFine/?start_date={day_str}&end_date={day_str}"
|
||
try:
|
||
req = urllib.request.Request(api_url)
|
||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||
body = json.loads(resp.read().decode("utf-8"))
|
||
except Exception as e:
|
||
logger.warning("新闻联播 API 请求失败: {}", e)
|
||
return result
|
||
|
||
raw_news = body.get("data", {}).get("news", [])
|
||
if not raw_news:
|
||
return result
|
||
|
||
# 提取日期
|
||
dates = {n.get("news_days", "") for n in raw_news if n.get("news_days")}
|
||
if dates:
|
||
d = min(dates)
|
||
result["date"] = f"{d[5:7]}月{d[8:10]}日"
|
||
result["source_date"] = day_str
|
||
|
||
# 转换为事件格式,跳过第 1 条(内容提要/开场白)
|
||
events: list[dict] = []
|
||
for n in raw_news:
|
||
sid = n.get("daily_sub_id", 0)
|
||
if sid <= 1: # 跳过"内容提要"
|
||
continue
|
||
title = n.get("news_title", "")
|
||
content = n.get("news_improve", "")
|
||
importance = _score_xwlb_importance(title, content)
|
||
events.append({
|
||
"title": title[:100],
|
||
"url": "", # 新闻联播无独立文章链接
|
||
"source_id": "xwlb",
|
||
"publish_time": n.get("news_days", ""),
|
||
"event": {
|
||
"stock_codes": [],
|
||
"company_names": [],
|
||
"industries": [],
|
||
"sentiment": "neutral",
|
||
"importance": importance,
|
||
"event_type": "新闻联播",
|
||
"summary": content[:80] if content else title[:80],
|
||
},
|
||
})
|
||
|
||
# 按重要度降序
|
||
events.sort(key=lambda e: (-e["event"]["importance"], e["title"]))
|
||
result["items"] = events
|
||
return result
|
||
|
||
|
||
def _load_article_urls_from_index(index_path: Path) -> list[dict]:
|
||
"""从 index.jsonl 中加载 stage=article 的条目(排除列表页)。"""
|
||
if not index_path.is_file():
|
||
return []
|
||
articles: list[dict] = []
|
||
for line in open(index_path, encoding="utf-8"):
|
||
try:
|
||
obj = json.loads(line)
|
||
if obj.get("stage") == "article" and obj.get("success"):
|
||
articles.append(obj)
|
||
except (json.JSONDecodeError, KeyError):
|
||
pass
|
||
return articles
|
||
|
||
|
||
def _extract_date_from_url(url: str) -> datetime | None:
|
||
"""从 URL 中提取发布日期(用于估算 24h 新鲜度)。"""
|
||
import re as _re2
|
||
patterns = [
|
||
_re2.compile(r'/(\d{4})[-/](\d{2})[-/](\d{2})/'),
|
||
_re2.compile(r'/(\d{4})(\d{2})(\d{2})/'),
|
||
_re2.compile(r'(\d{4})(\d{2})(\d{2})\.(?:s?html|pdf)'),
|
||
_re2.compile(r'/t(\d{4})(\d{2})(\d{2})_'),
|
||
]
|
||
for pat in patterns:
|
||
m = pat.search(url)
|
||
if m:
|
||
try:
|
||
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _collect_pipeline_stats(day_str: str) -> dict[str, Any]:
|
||
"""收集管道统计数据(仅计文章级条目 + 24h 新鲜度)。"""
|
||
now = datetime.now()
|
||
cutoff_24h = now - timedelta(hours=24)
|
||
|
||
raw_by_source: dict[str, int] = {}
|
||
raw_by_source_24h: dict[str, int] = {}
|
||
raw_total = 0
|
||
raw_total_24h = 0
|
||
|
||
for idx in Path("data/raw").glob(f"*/{day_str}/index.jsonl"):
|
||
src = idx.parent.parent.name
|
||
articles = _load_article_urls_from_index(idx)
|
||
n = len(articles)
|
||
name = _source_name(src)
|
||
raw_by_source[name] = n
|
||
raw_total += n
|
||
|
||
# 统计 24h 内文章
|
||
n_24h = 0
|
||
for art in articles:
|
||
dt = _extract_date_from_url(art.get("url", ""))
|
||
if dt and dt >= cutoff_24h:
|
||
n_24h += 1
|
||
raw_by_source_24h[name] = n_24h
|
||
raw_total_24h += n_24h
|
||
|
||
# cninfo raw(仅计文章级条目)
|
||
cninfo_raw = 0
|
||
for idx in Path("data/raw/cninfo").glob("*/index.jsonl"):
|
||
cninfo_raw += len(_load_article_urls_from_index(idx))
|
||
|
||
proc = _count_json(f"data/processed/*/{day_str}/*.json")
|
||
deduped = _count_json(f"data/deduped/{day_str}/uniques/*.json")
|
||
dup_path = Path(f"data/deduped/{day_str}/duplicates.jsonl")
|
||
dups = _count_jsonl(dup_path)
|
||
emb_count = _count_json(f"data/embeddings/{day_str}/*.json")
|
||
|
||
qdrant_count = 0
|
||
try:
|
||
from vectorstore import VectorStore, make_qdrant_client
|
||
c = make_qdrant_client()
|
||
s = VectorStore(c)
|
||
qdrant_count = s.count()
|
||
s.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"raw_total": raw_total,
|
||
"raw_total_24h": raw_total_24h,
|
||
"raw_by_source": raw_by_source,
|
||
"raw_by_source_24h": raw_by_source_24h,
|
||
"cninfo_raw": cninfo_raw,
|
||
"proc": proc,
|
||
"deduped": deduped,
|
||
"dups": dups,
|
||
"emb_count": emb_count,
|
||
"qdrant_count": qdrant_count,
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# AI 摘要
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def _generate_ai_summary(news: dict, cninfo: dict, day_str: str,
|
||
xwlb: dict | None = None) -> str:
|
||
"""LLM 生成 500 字以内日报摘要,囊括全部新闻、公告及新闻联播。"""
|
||
lines: list[str] = []
|
||
|
||
# 新闻联播(如有)
|
||
if xwlb and xwlb.get("items"):
|
||
items = xwlb["items"]
|
||
lines.append(f"## 新闻联播要闻 ({xwlb.get('date', '')}, {len(items)} 条, 按重要度排序)")
|
||
for e in items[:10]:
|
||
ev = e["event"]
|
||
lines.append(f"- [重要度{ev.get('importance', 0)}] {e['title']}")
|
||
|
||
# 新闻(已在 _collect_news_events 中过滤)
|
||
if news["high"]:
|
||
lines.append(f"## 过去 24 小时高重要度新闻 ({len(news['high'])} 条)")
|
||
for e in news["high"][:12]:
|
||
ev = e["event"]
|
||
sentiment = ev.get("sentiment", "")
|
||
s_icon = {"positive": "利好", "negative": "利空", "neutral": "中性"}.get(sentiment, "")
|
||
lines.append(f"- [{s_icon}][{ev.get('event_type', '')}] {e['title']}。{ev.get('summary', '')}")
|
||
|
||
# 公告/调研
|
||
if cninfo["high"]:
|
||
lines.append(f"## 近 {CNINFO_DAYS_BACK} 日重要公告/调研 ({len(cninfo['high'])} 条)")
|
||
for e in cninfo["high"][:8]:
|
||
ev = e.get("event", {})
|
||
lines.append(f"- [{ev.get('event_type', '公司公告')}] {e['title']}")
|
||
|
||
if not lines:
|
||
return ""
|
||
|
||
try:
|
||
from llm.client import load_llm_config, make_sync_client
|
||
config = load_llm_config()
|
||
client = make_sync_client(config)
|
||
return _llm_summarize(client, config.model, lines, day_str)
|
||
except Exception as e:
|
||
logger.warning("AI 摘要生成失败: {}", e)
|
||
return ""
|
||
|
||
|
||
def _split_lines_into_chunks(lines: list[str], max_chars: int = 3000) -> list[list[str]]:
|
||
"""将 lines 按 max_chars 分块,保证每条新闻(line)不被截断。"""
|
||
chunks: list[list[str]] = []
|
||
current: list[str] = []
|
||
current_len = 0
|
||
|
||
for line in lines:
|
||
line_len = len(line) + 1 # +1 for newline
|
||
if current and current_len + line_len > max_chars:
|
||
chunks.append(current)
|
||
current = []
|
||
current_len = 0
|
||
current.append(line)
|
||
current_len += line_len
|
||
|
||
if current:
|
||
chunks.append(current)
|
||
return chunks
|
||
|
||
|
||
def _llm_summarize(client, model: str, lines: list[str], day_str: str) -> str:
|
||
"""LLM 摘要:单块直接总结,多块先分段总结再合并。"""
|
||
chunks = _split_lines_into_chunks(lines)
|
||
|
||
if len(chunks) == 1:
|
||
return _llm_call(client, model, _build_prompt(chunks[0], day_str))
|
||
|
||
# 多块:每块独立总结
|
||
partials: list[str] = []
|
||
for i, chunk in enumerate(chunks, 1):
|
||
prompt = f"""以下是今日日报素材的第 {i}/{len(chunks)} 部分 (共 {len(lines)} 条, 本批 {len(chunk)} 条),请用要点总结,每条一行,以 "- " 开头:
|
||
|
||
{chr(10).join(chunk)}
|
||
|
||
直接输出要点列表:"""
|
||
result = _llm_call(client, model, prompt, max_tokens=800)
|
||
if result:
|
||
partials.append(result)
|
||
logger.info("AI 摘要: 分块 {}/{} 完成 ({} 字)", i, len(chunks), len(result))
|
||
|
||
if not partials:
|
||
logger.warning("AI 摘要: 所有分块均返回空")
|
||
return ""
|
||
|
||
if len(partials) < len(chunks):
|
||
logger.warning("AI 摘要: {}/{} 分块返回空, 仅合并成功部分", len(chunks) - len(partials), len(chunks))
|
||
|
||
# 合并:将各块摘要合成最终日报摘要
|
||
merge_prompt = f"""以下是 {len(partials)} 组分段摘要,请合并为一份完整的日报摘要 ({day_str}):
|
||
|
||
{chr(10).join(f'--- 第{i+1}组 ---{chr(10)}{p}' for i, p in enumerate(partials))}
|
||
|
||
请合并为要点总结,每条一行以 "- " 开头,要求:
|
||
1. 前 3 条为影响最大的事件,说明为什么重要
|
||
2. 汇总近 {CNINFO_DAYS_BACK} 日公司公告/调研核心信息
|
||
3. 市场情绪基调(利好/利空/中性)
|
||
4. 值得持续关注的行业或主题
|
||
5. 纯要点,不要开场白/结束语
|
||
6. 总字数 500 字以内
|
||
|
||
直接输出要点列表:"""
|
||
return _llm_call(client, model, merge_prompt, max_tokens=1500)
|
||
|
||
|
||
def _build_prompt(lines: list[str], day_str: str) -> str:
|
||
"""构建标准日报摘要 prompt。"""
|
||
return f"""以下是今日需要总结的全部内容(含新闻联播、财经新闻、公司公告),请据此生成日报摘要 ({day_str}):
|
||
|
||
{chr(10).join(lines)}
|
||
|
||
请用要点总结,每条一行,以 "- " 开头,要求:
|
||
1. 前 3 条为过去 24 小时影响最大的事件(优先参考新闻联播中的重大政策信号),说明为什么重要
|
||
2. 汇总近 {CNINFO_DAYS_BACK} 日重要公司公告/调研的核心信息
|
||
3. 市场情绪基调(利好/利空/中性)
|
||
4. 值得持续关注的行业或主题
|
||
5. 纯要点,不要开场白/结束语/标题
|
||
6. 总字数控制在 500 字以内
|
||
|
||
直接输出要点列表:"""
|
||
|
||
|
||
def _llm_call(client, model: str, prompt: str, max_tokens: int = 1500) -> str:
|
||
"""单次 LLM 调用,返回 strip 后的文本。
|
||
|
||
若 finish_reason 为 'length' 则说明达到 max_tokens 上限被截断。
|
||
"""
|
||
resp = client.chat.completions.create(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": "你是 A 股日报撰写助手,输出简洁、有洞察的新闻摘要。"},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
temperature=0.3,
|
||
max_tokens=max_tokens,
|
||
)
|
||
content = (resp.choices[0].message.content or "").strip()
|
||
finish = getattr(resp.choices[0], "finish_reason", None)
|
||
if finish == "length":
|
||
logger.warning(
|
||
"AI 摘要可能被截断: max_tokens={} finish_reason=length 实际输出 {} 字符",
|
||
max_tokens, len(content),
|
||
)
|
||
return content
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# HTML 渲染
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
_HTML_TEMPLATE = """<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>A 股 Deep Research 日报 — {date}_{time}</title>
|
||
<style>
|
||
:root {{ --bg: #f8f9fa; --card: #fff; --text: #212529; --muted: #6c757d;
|
||
--accent: #2563eb; --border: #dee2e6; --pos: #059669; --neg: #dc2626;
|
||
--neu: #6b7280; --radius: 10px; }}
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; padding-bottom: 3rem; }}
|
||
.container {{ max-width: 1000px; margin: 0 auto; padding: 1.2rem; }}
|
||
header {{ background: linear-gradient(135deg, #1e293b, #334155); color: #fff; padding: 2.5rem 0 1.8rem; text-align: center; }}
|
||
header h1 {{ font-size: 1.8rem; }}
|
||
header p {{ color: #94a3b8; margin-top: .4rem; }}
|
||
h2 {{ font-size: 1.3rem; margin: 2rem 0 .8rem; padding-bottom: .4rem; border-bottom: 2px solid var(--accent); }}
|
||
h3 {{ font-size: 1.05rem; margin: 1.2rem 0 .5rem; color: #374151; }}
|
||
|
||
.ai-summary {{ background: linear-gradient(135deg, #eff6ff, #f0fdf4); border: 1px solid #93c5fd; border-radius: var(--radius); padding: 1.2rem 1.5rem; margin: 1rem 0; line-height: 1.9; font-size: .95em; }}
|
||
|
||
.stats-grid {{ display: grid; grid-template-columns: repeat(6, 1fr); gap: .8rem; margin: 1rem 0; }}
|
||
.stat-card {{ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem .8rem; text-align: center; }}
|
||
.stat-card .num {{ font-size: 1.6rem; font-weight: 700; }}
|
||
.stat-card .label {{ font-size: .8em; color: var(--muted); margin-top: .2rem; }}
|
||
|
||
.source-grid {{ display: grid; grid-template-columns: repeat(6, 1fr); gap: .6rem; margin: .8rem 0; }}
|
||
.source-item {{ background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: .6rem; text-align: center; font-size: .85em; }}
|
||
.source-item .s-name {{ color: var(--muted); font-size: .8em; }}
|
||
.source-item .s-count {{ font-size: 1.2rem; font-weight: 600; }}
|
||
|
||
.sentiment-bar {{ display: flex; height: 24px; border-radius: 6px; overflow: hidden; margin: .5rem 0; }}
|
||
.sentiment-bar .pos {{ background: var(--pos); }}
|
||
.sentiment-bar .neg {{ background: var(--neg); }}
|
||
.sentiment-bar .neu {{ background: var(--neu); }}
|
||
.sentiment-legend {{ display: flex; gap: 1rem; margin: .5rem 0; font-size: .9em; }}
|
||
|
||
table {{ width: 100%; border-collapse: collapse; margin: .8rem 0; font-size: .93em; }}
|
||
th, td {{ border: 1px solid var(--border); padding: .5rem .7rem; text-align: left; }}
|
||
th {{ background: #f1f5f9; font-weight: 600; white-space: nowrap; }}
|
||
.event-row:hover {{ background: #f8fafc; }}
|
||
|
||
.badge {{ display: inline-block; padding: .1em .5em; border-radius: 10px; font-size: .78em; font-weight: 600; }}
|
||
.badge-pos {{ background: #d1fae5; color: #065f46; }}
|
||
.badge-neg {{ background: #fee2e2; color: #991b1b; }}
|
||
.badge-neu {{ background: #e5e7eb; color: #374151; }}
|
||
.imp {{ font-weight: 700; }}
|
||
.imp-5 {{ color: #dc2626; }} .imp-4 {{ color: #ea580c; }}
|
||
|
||
.subsection {{ margin: 1rem 0; }}
|
||
|
||
footer {{ text-align: center; color: var(--muted); font-size: .82em; margin-top: 3rem; padding-top: 1.2rem; border-top: 1px solid var(--border); }}
|
||
a {{ color: var(--accent); text-decoration: none; }}
|
||
a:hover {{ text-decoration: underline; }}
|
||
|
||
@media (max-width: 768px) {{
|
||
.stats-grid, .source-grid {{ grid-template-columns: repeat(3, 1fr); }}
|
||
}}
|
||
|
||
.xwlb-list ol {{ padding-left: 1.2em; }}
|
||
.xwlb-list li {{ padding: .35em 0; line-height: 1.55; border-bottom: 1px dashed var(--border); }}
|
||
.xwlb-num {{ color: var(--accent); font-weight: 600; margin-right: .3em; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="container">
|
||
<h1>📊 A 股 Deep Research 日报</h1>
|
||
<p>{date} · 生成于 {generated_at}</p>
|
||
</div>
|
||
</header>
|
||
<main class="container">
|
||
|
||
<!-- ====== 一、AI 摘要 ====== -->
|
||
<h2>一、AI 摘要</h2>
|
||
<div class="ai-summary">{ai_summary}</div>
|
||
|
||
<!-- ====== 二、新闻联播要闻 ====== -->
|
||
{xwlb_section}
|
||
|
||
<!-- ====== 三、重要事件:新闻 ====== -->
|
||
<h2>三、🔥 重要事件:新闻 <small style="color:var(--muted)">({raw_total_24h}/{raw_total} 篇 24h 内, importance ≥ {news_threshold}, 共 {news_high_count} 篇)</small></h2>
|
||
{news_table}
|
||
|
||
<!-- ====== 四、重要事件:公告/互动 ====== -->
|
||
<h2>四、📋 重要事件:公告 / 调研 / 互动 <small style="color:var(--muted)">(近 {cninfo_days} 日, importance ≥ {cninfo_threshold}, 共 {cninfo_high_count} 篇)</small></h2>
|
||
{cninfo_table}
|
||
|
||
<!-- ====== 五、数据总览 ====== -->
|
||
<h2>五、数据总览</h2>
|
||
|
||
<h3>5.1 M1 → M6 管道</h3>
|
||
<div class="stats-grid">
|
||
<div class="stat-card"><div class="num">{raw_total} <small style="font-size:.45em;color:var(--muted)">({raw_total_24h} 24h)</small></div><div class="label">M1 原始文章</div></div>
|
||
<div class="stat-card"><div class="num">{cninfo_raw}</div><div class="label">M1 cninfo</div></div>
|
||
<div class="stat-card"><div class="num">{proc}</div><div class="label">M2 正文提取</div></div>
|
||
<div class="stat-card"><div class="num">{deduped}</div><div class="label">M3 去重唯一</div></div>
|
||
<div class="stat-card"><div class="num">{emb_count}</div><div class="label">M5 向量</div></div>
|
||
<div class="stat-card"><div class="num">{qdrant_count}</div><div class="label">M6 Qdrant</div></div>
|
||
</div>
|
||
|
||
<h3>5.2 各源数据 <small style="color:var(--muted)">({raw_total_24h}/{raw_total} 篇 24h 内)</small></h3>
|
||
<div class="source-grid">
|
||
{source_cards}
|
||
</div>
|
||
|
||
<h3>5.3 情绪分布 <small style="color:var(--muted)">(最新抓取)</small></h3>
|
||
{sentiment_section}
|
||
|
||
<h3>5.4 重要度分布</h3>
|
||
<table>
|
||
<tr><th>重要度</th>{importance_headers}</tr>
|
||
<tr><td>数量</td>{importance_counts}</tr>
|
||
</table>
|
||
|
||
<h3>5.5 事件类型分布</h3>
|
||
<table>
|
||
<tr><th>事件类型</th><th>数量</th></tr>
|
||
{event_type_rows}
|
||
</table>
|
||
|
||
</main>
|
||
<footer>
|
||
<div class="container">
|
||
<p>A 股 Deep Research 私有投研平台 · 自动生成于 {generated_at}</p>
|
||
</div>
|
||
</footer>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _render_event_table(events: list[dict], show_source: bool = True,
|
||
show_summary: bool = True) -> str:
|
||
"""渲染事件表格。"""
|
||
if not events:
|
||
return "<p>暂无符合条件的数据</p>"
|
||
rows: list[str] = []
|
||
wl_codes = _load_watchlist_codes()
|
||
for i, e in enumerate(events, 1):
|
||
ev = e["event"]
|
||
sentiment = ev.get("sentiment", "")
|
||
icon = {"positive": "🟢", "negative": "🔴", "neutral": "⚪"}.get(sentiment, "")
|
||
badge_cls = {"positive": "badge-pos", "negative": "badge-neg"}.get(sentiment, "badge-neu")
|
||
imp = ev.get("importance", 0)
|
||
imp_cls = f"imp-{imp}" if imp >= 4 else ""
|
||
title = e["title"][:70]
|
||
src = _source_name(e.get("source_id", ""))
|
||
codes_in_event = {c.strip().split(".")[0] for c in (ev.get("stock_codes") or [])}
|
||
star = "⭐ " if codes_in_event & wl_codes else ""
|
||
code_str = f" <small>({','.join(list(codes_in_event)[:3])})</small>" if codes_in_event else ""
|
||
|
||
url = e.get("url", "")
|
||
title_cell = f'{star}<a href="{url}" target="_blank">{title}</a>{code_str}' if url else f"{star}{title}{code_str}"
|
||
|
||
cols = [
|
||
f"<td>{i}</td>",
|
||
f'<td><span class="badge {badge_cls}">{icon}</span></td>',
|
||
f"<td>{title_cell}</td>",
|
||
]
|
||
if show_source:
|
||
cols.append(f"<td>{src}</td>")
|
||
cols.append(f'<td class="imp {imp_cls}">{imp}</td>')
|
||
cols.append(f"<td>{ev.get('event_type', '')}</td>")
|
||
if show_summary:
|
||
cols.append(f"<td>{(ev.get('summary', '') or '')[:60]}</td>")
|
||
|
||
rows.append(f'<tr class="event-row">{"".join(cols)}</tr>')
|
||
|
||
headers = ["#", "", "标题"]
|
||
if show_source:
|
||
headers.append("源")
|
||
headers += ["重要度", "事件类型"]
|
||
if show_summary:
|
||
headers.append("摘要")
|
||
header_row = "".join(f"<th>{h}</th>" for h in headers)
|
||
return f"<table><tr>{header_row}</tr>{''.join(rows)}</table>"
|
||
|
||
|
||
def _render_source_cards(raw_by_source: dict[str, int],
|
||
raw_by_source_24h: dict[str, int],
|
||
cninfo_raw: int) -> str:
|
||
"""渲染源数据卡片,每行 6 个,显示总量和 24h 新鲜数。"""
|
||
cards: list[str] = []
|
||
# 新闻源
|
||
for name, count in sorted(raw_by_source.items()):
|
||
fresh = raw_by_source_24h.get(name, 0)
|
||
cards.append(
|
||
f'<div class="source-item">'
|
||
f'<div class="s-count">{count} <small style="font-size:.65em;color:var(--muted)">({fresh} 24h)</small></div>'
|
||
f'<div class="s-name">{name}</div>'
|
||
f'</div>'
|
||
)
|
||
# cninfo
|
||
cards.append(
|
||
f'<div class="source-item" style="border-color:var(--accent)">'
|
||
f'<div class="s-count">{cninfo_raw}</div>'
|
||
f'<div class="s-name">📋 cninfo</div>'
|
||
f'</div>'
|
||
)
|
||
return "\n".join(cards)
|
||
|
||
|
||
def _render_xwlb_section(xwlb: dict | None) -> str:
|
||
"""渲染新闻联播要闻 HTML 板块(重要事件格式,按重要度排序)。"""
|
||
if not xwlb or not xwlb.get("items"):
|
||
return ""
|
||
|
||
items = xwlb["items"]
|
||
date_label = xwlb.get("date", "")
|
||
source_date = xwlb.get("source_date", "")
|
||
|
||
table_html = _render_event_table(items, show_source=False, show_summary=False)
|
||
|
||
return f"""<h2>二、📺 新闻联播 <small style="color:var(--muted)">({date_label}, 共 {len(items)} 条, 按重要度排序)</small></h2>
|
||
{table_html}
|
||
<p style="color:var(--muted);font-size:.8em;margin-top:.5em">
|
||
来源: 央视《新闻联播》· 数据取自 doorcome API (xwlbFine) · {source_date}
|
||
</p>"""
|
||
|
||
|
||
def _render_html(news: dict, cninfo: dict, pipeline: dict,
|
||
ai_summary: str, day_str: str,
|
||
xwlb: dict | None = None) -> str:
|
||
"""组装完整 HTML(M10 起废弃:日报已改为结构化入库,此函数不再被调用,保留以便回退)。"""
|
||
|
||
# AI 摘要 → HTML
|
||
summary_html = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", ai_summary)
|
||
summary_html = _re.sub(r"\*(.+?)\*", r"<em>\1</em>", summary_html)
|
||
summary_html = _re.sub(r"`(.+?)`", r"<code>\1</code>", summary_html)
|
||
if summary_html.strip():
|
||
lines = summary_html.strip().splitlines()
|
||
if any(ln.strip().startswith("- ") for ln in lines):
|
||
items = []
|
||
for ln in lines:
|
||
s = ln.strip()
|
||
if s.startswith("- "):
|
||
items.append(f"<li>{s[2:]}</li>")
|
||
elif s:
|
||
items.append(f"<li>{s}</li>")
|
||
summary_html = f"<ul style='padding-left:1.5rem;margin:.5rem 0'>{''.join(items)}</ul>"
|
||
else:
|
||
summary_html = summary_html.replace("\n", "<br>")
|
||
else:
|
||
summary_html = "<p>AI 摘要暂不可用</p>"
|
||
|
||
# 新闻表格
|
||
news_table = _render_event_table(news["high"])
|
||
|
||
# cninfo 表格
|
||
cninfo_table = _render_event_table(cninfo["high"], show_source=False, show_summary=False)
|
||
|
||
# 源数据卡片
|
||
source_cards = _render_source_cards(
|
||
pipeline["raw_by_source"],
|
||
pipeline.get("raw_by_source_24h", {}),
|
||
pipeline["cninfo_raw"],
|
||
)
|
||
|
||
# 情绪
|
||
s = news["sentiments"]
|
||
pos = s.get("positive", 0)
|
||
neg = s.get("negative", 0)
|
||
neu = s.get("neutral", 0)
|
||
total_s = max(pos + neg + neu, 1)
|
||
sentiment_section = (
|
||
f'<div class="sentiment-bar">'
|
||
f'<div class="pos" style="width:{pos/total_s*100:.0f}%"></div>'
|
||
f'<div class="neg" style="width:{neg/total_s*100:.0f}%"></div>'
|
||
f'<div class="neu" style="width:{neu/total_s*100:.0f}%"></div>'
|
||
f'</div>'
|
||
f'<div class="sentiment-legend">'
|
||
f'<span>🟢 利好 {pos} ({pos/total_s:.0%})</span>'
|
||
f'<span>🔴 利空 {neg} ({neg/total_s:.0%})</span>'
|
||
f'<span>⚪ 中性 {neu} ({neu/total_s:.0%})</span>'
|
||
f'</div>'
|
||
)
|
||
|
||
# 重要度
|
||
imps = news["importances"]
|
||
imp_keys = sorted(imps.keys())
|
||
importance_headers = "".join(f"<th>等级 {k}</th>" for k in imp_keys)
|
||
importance_counts = "".join(f"<td>{imps[k]}</td>" for k in imp_keys)
|
||
|
||
# 事件类型
|
||
et = news["event_types"]
|
||
event_type_rows = "\n".join(
|
||
f"<tr><td>{k}</td><td>{v}</td></tr>" for k, v in et.items()
|
||
)
|
||
|
||
return _HTML_TEMPLATE.format(
|
||
date=day_str,
|
||
time=datetime.now().strftime("%H%M"),
|
||
generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
ai_summary=summary_html,
|
||
xwlb_section=_render_xwlb_section(xwlb) if xwlb else "",
|
||
news_high_count=len(news["high"]),
|
||
news_threshold=news.get("hi_threshold", 4),
|
||
news_table=news_table,
|
||
cninfo_high_count=len(cninfo["high"]),
|
||
cninfo_threshold=cninfo.get("hi_threshold", 4),
|
||
cninfo_days=CNINFO_DAYS_BACK,
|
||
cninfo_table=cninfo_table,
|
||
raw_total=pipeline["raw_total"],
|
||
raw_total_24h=pipeline.get("raw_total_24h", pipeline["raw_total"]),
|
||
cninfo_raw=pipeline["cninfo_raw"],
|
||
proc=pipeline["proc"],
|
||
deduped=pipeline["deduped"],
|
||
emb_count=pipeline["emb_count"],
|
||
qdrant_count=pipeline["qdrant_count"],
|
||
source_cards=source_cards,
|
||
sentiment_section=sentiment_section,
|
||
importance_headers=importance_headers,
|
||
importance_counts=importance_counts,
|
||
event_type_rows=event_type_rows,
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 生成 + 上传
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
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)
|
||
|
||
# 收集数据
|
||
try:
|
||
news = _collect_news_events(day_str)
|
||
cninfo = _collect_cninfo_events(day_str, days_back=CNINFO_DAYS_BACK)
|
||
pipeline = _collect_pipeline_stats(day_str)
|
||
xwlb = _collect_xwlb(day_str)
|
||
except Exception as e:
|
||
logger.exception("收集日报数据失败: {}", e)
|
||
return None
|
||
|
||
if news["total"] == 0 and cninfo["total"] == 0 and not xwlb.get("items"):
|
||
logger.warning("{} 无数据,跳过日报生成", day_str)
|
||
return None
|
||
|
||
# AI 摘要(新闻联播 + 新闻 + cninfo)
|
||
ai_summary = _generate_ai_summary(news, cninfo, 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
|
||
|
||
conn = connect()
|
||
try:
|
||
report_id = save_report(conn, report)
|
||
finally:
|
||
conn.close()
|
||
except Exception as e:
|
||
logger.exception("日报入库失败: {}", e)
|
||
return None
|
||
|
||
logger.info("日报已入库: report_id={}", report_id)
|
||
return report_id
|
||
|
||
|
||
def _upload(html_path: Path, file_tag: str) -> bool:
|
||
"""上传 HTML 报告到 Web 服务器(M10 起废弃:不再被调用,保留以便回退)。"""
|
||
today_str = date.today().strftime("%Y%m%d")
|
||
remote_dir = f"{UPLOAD_BASE}/{today_str}/"
|
||
logger.info("上传日报到 {}:{}", UPLOAD_HOST, remote_dir)
|
||
|
||
try:
|
||
r1 = subprocess.run(
|
||
["ssh", UPLOAD_HOST, f"mkdir -p {remote_dir}"],
|
||
timeout=15, capture_output=True, text=True,
|
||
)
|
||
if r1.returncode != 0:
|
||
logger.warning("创建远程目录失败: {}", r1.stderr.strip())
|
||
return False
|
||
r2 = subprocess.run(
|
||
["scp", str(html_path), f"{UPLOAD_HOST}:{remote_dir}finance_news_daily_{file_tag}.html"],
|
||
timeout=30, capture_output=True, text=True,
|
||
)
|
||
if r2.returncode != 0:
|
||
logger.warning("上传日报失败: {}", r2.stderr.strip())
|
||
return False
|
||
logger.info("上传完成: http://doorcome.cn/echart/research/{}/", today_str)
|
||
return True
|
||
except Exception as e:
|
||
logger.warning("上传日报异常(不阻塞): {}", e)
|
||
return False
|