Files
stock-advisor/engine/notifier.py
T

273 lines
12 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
舆情驱动自动化:定期扫描最新新闻 → 重要度评分 → 邮件通知
- 邮件:SMTPplain/starttls/ssl 三种模式),配置来自设置区
- 重要度评分(0-100):
类别基础分(公司40/业绩35/机构观点30/行业25/市场15
+ 情感绝对值权重(|sentiment| × 权重系数 × 100
+ 重要关键词命中(+12/个,上限 2 个)
+ 关联个股数(+5/只)
- 去重:记录已处理的最大新闻 idmonitor_last_news_id),只扫描新增
- 每次扫描结果写入 notification_log
"""
import logging
import smtplib
import threading
import time
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate
from database import query, execute, query_one
from settings import mail_config, monitor_config, monitor_state, set_monitor_state, \
quiet_config, in_quiet_period
log = logging.getLogger("notifier")
_scan_lock = threading.Lock()
CATEGORY_BASE = {"公司": 40, "业绩": 35, "机构观点": 30, "行业": 25, "市场": 15}
STRONG_WORDS = ["回购", "中标", "减持", "问询", "停牌", "重组", "预警",
"上调", "下调", "超预期", "不及预期", "增持", "定增", "退市", "处罚"]
# ===================================================================== 邮件
def send_email(subject, html_body, to=None, cfg=None, sender_name=None, attachments=None):
"""发送 HTML 邮件。cfg 来自设置;attachments: [{filename, content(bytes)}];失败抛异常"""
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
cfg = cfg or mail_config()
to = to or cfg["email_to"]
if attachments:
msg = MIMEMultipart()
msg.attach(MIMEText(html_body, "html", "utf-8"))
for att in attachments:
part = MIMEBase("application", "octet-stream")
part.set_payload(att.get("content") or b"")
encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment",
filename=("utf-8", "", att.get("filename", "report.html")))
msg.attach(part)
else:
msg = MIMEText(html_body, "html", "utf-8")
msg["From"] = formataddr((sender_name or cfg["sender_name"], cfg["smtp_user"]))
msg["To"] = to
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
mode = cfg.get("smtp_mode", "plain")
if mode == "ssl":
server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=30)
else:
server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=30)
server.ehlo()
if mode == "starttls":
server.starttls()
server.ehlo()
server.login(cfg["smtp_user"], cfg["smtp_pass"])
server.sendmail(cfg["smtp_user"], [to], msg.as_string())
server.quit()
return True
# ===================================================================== 重要度
def importance_score(news, cfg):
"""返回 (score, [原因...])"""
score = CATEGORY_BASE.get(news.get("category", "市场"), 15)
reasons = [f"{news.get('category','市场')}类"]
# 情感
sent = abs(float(news.get("sentiment") or 0))
if sent > 0.05:
bonus = round(sent * cfg.get("sentiment_weight", 0.2) * 100)
score += bonus
reasons.append(f"情感强度{sent:.2f}(+{bonus})")
# 关键词
text = (news.get("title") or "") + (news.get("content") or "")
hit = 0
for kw in cfg.get("keywords", []):
if kw and kw in text:
hit += 1
if hit > 2:
break
if hit:
score += hit * 12
reasons.append(f"关键词×{hit}(+{hit*12})")
# 关联个股
related = [c for c in (news.get("related_stocks") or "").split(",") if c]
if related:
score += min(len(related), 3) * 5
reasons.append(f"关联{len(related)}只个股(+{min(len(related),3)*5})")
return min(score, 100), reasons
# ===================================================================== 扫描
def _fetch_related_names(codes):
names = {}
if not codes:
return names
for c in codes:
r = query_one("SELECT name FROM stocks WHERE code=?", (c,))
if r:
names[c] = r["name"]
return names
def build_email_html(items):
"""把重要新闻渲染成 HTML 邮件正文"""
rows = []
for it in items:
n = it["news"]
tone = "利好" if n["sentiment"] > 0 else ("利空" if n["sentiment"] < 0 else "中性")
tone_color = "#e03e3e" if tone == "利好" else ("#17a34a" if tone == "利空" else "#888")
names = "、".join(f"{k}({v})" for k, v in it["stocks"].items()) or "—"
rows.append(f"""
<tr style="border-bottom:1px solid #eee;">
<td style="padding:10px;">
<div style="font-size:15px;font-weight:bold;">{n['title']}</div>
<div style="color:#888;font-size:12px;margin:4px 0;">{n['category']} · {n['source']} · {n['publish_date']}</div>
<div style="font-size:12px;">重要度 <b>{it['score']:.0f}</b> 情感 <b style="color:{tone_color};">{tone}{n['sentiment']:+.2f}</b> 关联:{names}</div>
<div style="color:#555;font-size:13px;margin-top:4px;">{n['content'][:120]}{'…' if len(n['content']) > 120 else ''}</div>
</td>
</tr>""")
return f"""
<html><body style="font-family:Microsoft YaHei,Arial,sans-serif;background:#f5f6f8;padding:20px;">
<div style="max-width:680px;margin:auto;background:#fff;border-radius:8px;overflow:hidden;border:1px solid #e5e7eb;">
<div style="background:#1e293b;color:#fff;padding:16px 20px;">
<div style="font-size:18px;font-weight:bold;">📰 舆情监控 · 重要资讯通知</div>
<div style="font-size:12px;opacity:.8;margin-top:4px;">智能荐股系统自动扫描 · {time.strftime('%Y-%m-%d %H:%M:%S')}</div>
</div>
<div style="padding:6px 20px 20px;">
<p style="color:#555;font-size:13px;">本次扫描发现 <b>{len(items)}</b> 条重要资讯(重要度达到阈值):</p>
<table style="width:100%;border-collapse:collapse;">{''.join(rows)}</table>
</div>
<div style="background:#f8fafc;padding:10px 20px;color:#94a3b8;font-size:11px;text-align:center;">
本邮件由系统自动生成,内容基于模拟数据,仅供演示,不构成投资建议。可在系统「设置」中关闭通知。
</div>
</div></body></html>"""
def scan_news(force=False):
"""
扫描一次新闻:找出新增且重要度达标的新闻,发送邮件。
force=True 时也扫描历史未通知过的(用于手动测试)。
返回 {"checked": n, "important": n, "sent": n, "failed": n}
"""
with _scan_lock:
cfg = monitor_config()
if not cfg["enabled"] and not force:
return {"error": "监控未启用"}
news_rows = query("SELECT id, title, content, source, category, publish_date, "
"related_stocks, sentiment FROM news ORDER BY id ASC")
if not news_rows:
return {"checked": 0, "important": 0, "sent": 0, "failed": 0}
max_id = max(n["id"] for n in news_rows)
state = monitor_state()
last_id = state["last_news_id"]
# 首次运行:仅记录水位,不通知历史新闻
if last_id == 0:
set_monitor_state(last_news_id=max_id, last_scan=time.strftime("%Y-%m-%d %H:%M:%S"), last_sent=0)
return {"checked": 0, "important": 0, "sent": 0, "failed": 0, "initialized": True}
candidates = [n for n in news_rows if n["id"] > last_id]
important = []
for n in candidates:
score, _r = importance_score(n, cfg)
if score >= cfg["importance_threshold"]:
important.append({"news": n, "score": score, "reasons": _r})
sent, failed = 0, 0
if important:
# 静默期判断:命中则本次不发送邮件(仍记账推进水位,避免静默结束后补发积压)
qc = quiet_config("monitor")
quiet = qc["enabled"] and in_quiet_period(qc["ranges"])
omitted = 0
if quiet:
status, msg = "quiet", "静默期内已抑制发送"
else:
# 单封邮件最多带 20 条,避免超大邮件
shown = important[:20]
omitted = len(important) - len(shown)
items = [{"news": it["news"], "score": it["score"],
"stocks": _fetch_related_names([c for c in (it["news"]["related_stocks"] or "").split(",") if c])}
for it in shown]
subject = f"[舆情监控] {len(important)} 条重要资讯:{important[0]['news']['title'][:24]}" + \
(f" 等{len(important)}条" if len(important) > 1 else "")
try:
html = build_email_html(items)
if omitted:
html += f"<p style='color:#888;font-size:12px'>……另有 {omitted} 条重要资讯已省略,详见系统。</p>"
if cfg.get("email_enabled", True):
send_email(subject, html)
status, msg = "sent", ""
else:
status, msg = "skipped", "邮件功能未启用"
except Exception as e:
status, msg = "failed", str(e)
failed = len(important)
log.warning("notify email fail: %s", e)
# 写日志
for it in important:
execute(
"INSERT INTO notification_log(news_id, title, category, sentiment, importance, related, status, message) "
"VALUES(?,?,?,?,?,?,?,?)",
(it["news"]["id"], it["news"]["title"], it["news"]["category"],
it["news"]["sentiment"], round(it["score"], 1),
it["news"]["related_stocks"], status, msg))
if status == "sent":
sent = len(important)
# 更新水位:前进到已处理的最后一条
if candidates:
set_monitor_state(last_news_id=max(max_id, last_id),
last_scan=time.strftime("%Y-%m-%d %H:%M:%S"), last_sent=sent)
return {"checked": len(candidates), "important": len(important),
"sent": sent, "failed": failed}
def notification_log(limit=50):
return query("SELECT * FROM notification_log ORDER BY id DESC LIMIT ?", (limit,))
# ===================================================================== 调度器
class MonitorThread(threading.Thread):
"""后台调度:每 interval 分钟扫描一次"""
def __init__(self):
super().__init__(daemon=True, name="monitor")
self._stop = threading.Event()
def stop(self):
self._stop.set()
def run(self):
log.info("舆情监控调度器启动")
while not self._stop.is_set():
try:
cfg = monitor_config()
if cfg["enabled"]:
try:
r = scan_news()
if r.get("checked"):
log.info("monitor scan: %s", r)
except Exception as e:
log.warning("monitor scan error: %s", e)
except Exception as e:
log.warning("monitor loop error: %s", e)
self._stop.wait(cfg.get("interval_min", 30) * 60)
log.info("舆情监控调度器停止")
_monitor = None
def start_monitor():
global _monitor
if _monitor and _monitor.is_alive():
return _monitor
_monitor = MonitorThread()
_monitor.start()
return _monitor