Files

274 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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