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
This commit is contained in:
@@ -75,7 +75,13 @@ def create_app(crawler) -> Flask:
|
|||||||
if channel == 'email':
|
if channel == 'email':
|
||||||
if not c.config.email:
|
if not c.config.email:
|
||||||
return {"error": "Email config not set"}
|
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':
|
elif channel == 'webhook':
|
||||||
if not c.config.webhook_url:
|
if not c.config.webhook_url:
|
||||||
return {"error": "Webhook URL not set"}
|
return {"error": "Webhook URL not set"}
|
||||||
|
@@ -8,11 +8,13 @@ import requests
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from app.crawlers.base import BaseCrawler
|
from app.crawlers.base import BaseCrawler
|
||||||
|
from app.services import notifications as notif
|
||||||
|
|
||||||
|
|
||||||
class BarronsCrawler(BaseCrawler):
|
class BarronsCrawler(BaseCrawler):
|
||||||
def __init__(self, config, logger):
|
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.url = "https://www.barrons.com/market-data/stocks/stock-picks?mod=BOL_TOPNAV"
|
||||||
self.headers = {
|
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'
|
'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
|
self.stats['errors'] += 1
|
||||||
return []
|
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
|
||||||
|
@@ -95,9 +95,11 @@ class BaseCrawler(ABC):
|
|||||||
|
|
||||||
def _send_notifications(self, items: List[Dict]) -> None:
|
def _send_notifications(self, items: List[Dict]) -> None:
|
||||||
sent = False
|
sent = False
|
||||||
|
# Build subject/body via hook for consistency across crawlers
|
||||||
|
subject, body = self._build_email(items)
|
||||||
if self.config.email:
|
if self.config.email:
|
||||||
try:
|
try:
|
||||||
notif.send_email(items, self.config.email)
|
notif.send_custom_email(subject, body, self.config.email)
|
||||||
sent = True
|
sent = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"電子郵件通知失敗: {e}")
|
self.logger.error(f"電子郵件通知失敗: {e}")
|
||||||
@@ -116,6 +118,23 @@ class BaseCrawler(ABC):
|
|||||||
if sent:
|
if sent:
|
||||||
self.stats['last_notification'] = datetime.now().isoformat()
|
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 ---
|
# --- Run loop ---
|
||||||
def _signal_handler(self, signum, frame):
|
def _signal_handler(self, signum, frame):
|
||||||
self.logger.info("收到停止信號,正在關閉...")
|
self.logger.info("收到停止信號,正在關閉...")
|
||||||
|
@@ -8,7 +8,6 @@ import requests
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from app.crawlers.base import BaseCrawler
|
from app.crawlers.base import BaseCrawler
|
||||||
from app.services import notifications as notif
|
|
||||||
|
|
||||||
|
|
||||||
class OpenInsiderCrawler(BaseCrawler):
|
class OpenInsiderCrawler(BaseCrawler):
|
||||||
@@ -128,35 +127,15 @@ class OpenInsiderCrawler(BaseCrawler):
|
|||||||
self.logger.info(f"OpenInsider:解析完成,擷取 {len(items)} 筆交易")
|
self.logger.info(f"OpenInsider:解析完成,擷取 {len(items)} 筆交易")
|
||||||
return 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)}筆)"
|
subject = f"OpenInsider 內部人交易異動 - {self.symbol} ({len(items)}筆)"
|
||||||
lines = []
|
lines = []
|
||||||
for it in items[:10]:
|
for it in items[:10]:
|
||||||
lines.append(f"• {it['title']}")
|
lines.append(f"• {it.get('title','')}")
|
||||||
body = (
|
body = (
|
||||||
f"發現 {len(items)} 筆新的內部人交易異動(OpenInsider):\n\n" + "\n".join(lines) + "\n\n"
|
f"發現 {len(items)} 筆新的內部人交易異動(OpenInsider):\n\n" + "\n".join(lines) + "\n\n"
|
||||||
f"抓取時間:{datetime.now().isoformat()}\n來源:{self.url}"
|
f"抓取時間:{datetime.now().isoformat()}\n來源:{self.url}"
|
||||||
)
|
)
|
||||||
|
return subject, body
|
||||||
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()
|
|
||||||
|
|
||||||
|
@@ -21,28 +21,7 @@ def format_email_body(new_picks: List[Dict]) -> str:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
def send_email(new_picks: List[Dict], 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
|
|
||||||
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:
|
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg['From'] = cfg.from_email
|
msg['From'] = cfg.from_email
|
||||||
msg['To'] = cfg.to_email
|
msg['To'] = cfg.to_email
|
||||||
@@ -63,6 +42,16 @@ def send_custom_email(subject: str, body: str, cfg: EmailConfig) -> None:
|
|||||||
server.quit()
|
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:
|
def send_webhook(new_picks: List[Dict], url: str) -> None:
|
||||||
message = f"🚨 發現 {len(new_picks)} 條新的 Barron's 股票推薦!\n\n"
|
message = f"🚨 發現 {len(new_picks)} 條新的 Barron's 股票推薦!\n\n"
|
||||||
for pick in new_picks[:5]:
|
for pick in new_picks[:5]:
|
||||||
|
Reference in New Issue
Block a user