From b2c58c0560a0d56a0969949ff600122992687f85 Mon Sep 17 00:00:00 2001 From: MH Hung Date: Thu, 4 Sep 2025 22:59:51 +0800 Subject: [PATCH] refactor(notifications): unify email sending via _send_email; standardize crawler notifications\n\n- Extract _send_email and have send_email/send_custom_email share it\n- BaseCrawler: centralize _send_notifications and add _build_email hook\n- BarronsCrawler: override _build_email to keep original subject/body\n- OpenInsiderCrawler: remove custom _send_notifications, add _build_email\n- /notify_test: use crawler _build_email + send_custom_email for emails --- app/api/server.py | 8 +++++++- app/crawlers/barrons.py | 9 ++++++++- app/crawlers/base.py | 21 ++++++++++++++++++++- app/crawlers/openinsider.py | 31 +++++-------------------------- app/services/notifications.py | 33 +++++++++++---------------------- 5 files changed, 51 insertions(+), 51 deletions(-) diff --git a/app/api/server.py b/app/api/server.py index 567686f..c4a6adc 100644 --- a/app/api/server.py +++ b/app/api/server.py @@ -75,7 +75,13 @@ def create_app(crawler) -> Flask: if channel == 'email': if not c.config.email: return {"error": "Email config not set"} - notif.send_email(test_pick, c.config.email) + # Build subject/body using crawler's formatting hook for consistency + if hasattr(c, '_build_email'): + subject, body = c._build_email(test_pick) + else: + subject = f"{getattr(c, 'name', '通知測試')}(測試)" + body = notif.format_email_body(test_pick) + notif.send_custom_email(subject, body, c.config.email) elif channel == 'webhook': if not c.config.webhook_url: return {"error": "Webhook URL not set"} diff --git a/app/crawlers/barrons.py b/app/crawlers/barrons.py index 9791700..6ec47f6 100644 --- a/app/crawlers/barrons.py +++ b/app/crawlers/barrons.py @@ -8,11 +8,13 @@ import requests from bs4 import BeautifulSoup from app.crawlers.base import BaseCrawler +from app.services import notifications as notif class BarronsCrawler(BaseCrawler): def __init__(self, config, logger): - super().__init__(name="Barron's 股票推薦", config=config, logger=logger, data_filename='barrons_data.json') + # Name used in generic notifications; include emoji to match previous subject + super().__init__(name="📈 Barron's 新股票推薦", config=config, logger=logger, data_filename='barrons_data.json') self.url = "https://www.barrons.com/market-data/stocks/stock-picks?mod=BOL_TOPNAV" self.headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' @@ -68,3 +70,8 @@ class BarronsCrawler(BaseCrawler): self.stats['errors'] += 1 return [] + # Keep Barron's specific email formatting (subject + body) + def _build_email(self, items: List[Dict]): + subject = f"📈 Barron's 新股票推薦 ({len(items)}條)" + body = notif.format_email_body(items) + return subject, body diff --git a/app/crawlers/base.py b/app/crawlers/base.py index 986a847..e9e28b5 100644 --- a/app/crawlers/base.py +++ b/app/crawlers/base.py @@ -95,9 +95,11 @@ class BaseCrawler(ABC): def _send_notifications(self, items: List[Dict]) -> None: sent = False + # Build subject/body via hook for consistency across crawlers + subject, body = self._build_email(items) if self.config.email: try: - notif.send_email(items, self.config.email) + notif.send_custom_email(subject, body, self.config.email) sent = True except Exception as e: self.logger.error(f"電子郵件通知失敗: {e}") @@ -116,6 +118,23 @@ class BaseCrawler(ABC): if sent: self.stats['last_notification'] = datetime.now().isoformat() + def _build_email(self, items: List[Dict]): + """Construct a generic email subject and body. + + Subclasses can override to customize content/format. + """ + subject = f"{self.name} ({len(items)}條)" + lines = [] + for pick in items: + line = f"📊 {pick.get('title','').strip()}\n" + if pick.get('link'): + line += f"🔗 {pick['link']}\n" + line += f"🕒 {pick.get('scraped_at', datetime.now().isoformat())}\n" + line += "-" * 60 + "\n" + lines.append(line) + body = f"發現 {len(items)} 條新內容:\n\n" + "".join(lines) + return subject, body + # --- Run loop --- def _signal_handler(self, signum, frame): self.logger.info("收到停止信號,正在關閉...") diff --git a/app/crawlers/openinsider.py b/app/crawlers/openinsider.py index 893afdb..332d9c9 100644 --- a/app/crawlers/openinsider.py +++ b/app/crawlers/openinsider.py @@ -8,7 +8,6 @@ import requests from bs4 import BeautifulSoup from app.crawlers.base import BaseCrawler -from app.services import notifications as notif class OpenInsiderCrawler(BaseCrawler): @@ -128,35 +127,15 @@ class OpenInsiderCrawler(BaseCrawler): self.logger.info(f"OpenInsider:解析完成,擷取 {len(items)} 筆交易") return items - def _send_notifications(self, items: List[Dict]) -> None: + # Use BaseCrawler._send_notifications for unified flow + + def _build_email(self, items: List[Dict]): subject = f"OpenInsider 內部人交易異動 - {self.symbol} ({len(items)}筆)" lines = [] for it in items[:10]: - lines.append(f"• {it['title']}") + lines.append(f"• {it.get('title','')}") body = ( f"發現 {len(items)} 筆新的內部人交易異動(OpenInsider):\n\n" + "\n".join(lines) + "\n\n" f"抓取時間:{datetime.now().isoformat()}\n來源:{self.url}" ) - - sent = False - if self.config.email: - try: - notif.send_custom_email(subject, body, self.config.email) - sent = True - except Exception as e: - self.logger.error(f"電子郵件通知失敗: {e}") - if self.config.webhook_url: - try: - notif.send_text_webhook(subject + "\n\n" + body, self.config.webhook_url) - sent = True - except Exception as e: - self.logger.error(f"Webhook 通知失敗: {e}") - if self.config.discord_webhook: - try: - notif.send_text_discord(title=subject, description=f"{self.symbol} 內部人交易更新(OpenInsider)", lines=lines[:10], webhook=self.config.discord_webhook) - sent = True - except Exception as e: - self.logger.error(f"Discord 通知失敗: {e}") - if sent: - self.stats['last_notification'] = datetime.now().isoformat() - + return subject, body diff --git a/app/services/notifications.py b/app/services/notifications.py index 90daca7..e496f43 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -21,28 +21,7 @@ def format_email_body(new_picks: List[Dict]) -> str: return body -def send_email(new_picks: List[Dict], cfg: EmailConfig) -> None: - msg = MIMEMultipart() - msg['From'] = cfg.from_email - msg['To'] = cfg.to_email - msg['Subject'] = f"📈 Barron's 新股票推薦 ({len(new_picks)}條)" - msg.attach(MIMEText(format_email_body(new_picks), 'plain', 'utf-8')) - - if cfg.smtp_security == 'ssl': - server = smtplib.SMTP_SSL(cfg.smtp_server, cfg.smtp_port) - else: - server = smtplib.SMTP(cfg.smtp_server, cfg.smtp_port) - server.ehlo() - if cfg.smtp_security == 'starttls': - server.starttls() - server.ehlo() - - server.login(cfg.username, cfg.password) - server.send_message(msg) - server.quit() - - -def send_custom_email(subject: str, body: str, cfg: EmailConfig) -> None: +def _send_email(subject: str, body: str, cfg: EmailConfig) -> None: msg = MIMEMultipart() msg['From'] = cfg.from_email msg['To'] = cfg.to_email @@ -63,6 +42,16 @@ def send_custom_email(subject: str, body: str, cfg: EmailConfig) -> None: server.quit() +def send_email(new_picks: List[Dict], cfg: EmailConfig) -> None: + subject = f"📈 Barron's 新股票推薦 ({len(new_picks)}條)" + body = format_email_body(new_picks) + _send_email(subject, body, cfg) + + +def send_custom_email(subject: str, body: str, cfg: EmailConfig) -> None: + _send_email(subject, body, cfg) + + def send_webhook(new_picks: List[Dict], url: str) -> None: message = f"🚨 發現 {len(new_picks)} 條新的 Barron's 股票推薦!\n\n" for pick in new_picks[:5]: