refactor: modularize project structure and separate API from crawlers
- Introduce app/ package with config, services (storage, notifications), API server, and crawler modules - Add BaseCrawler and BarronsCrawler; extract notifications and storage - Keep enhanced_crawler.py as back-compat entry delegating to app.runner - Add template crawler for future sites - Update README with new structure and usage - Extend .env.template with DATA_DIR/LOG_DIR options
This commit is contained in:
79
app/services/notifications.py
Normal file
79
app/services/notifications.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app.config import EmailConfig
|
||||
|
||||
|
||||
def format_email_body(new_picks: List[Dict]) -> str:
|
||||
body = f"發現 {len(new_picks)} 條新的股票推薦:\n\n"
|
||||
for pick in new_picks:
|
||||
body += f"📊 {pick['title']}\n"
|
||||
if pick.get('link'):
|
||||
body += f"🔗 {pick['link']}\n"
|
||||
body += f"🕒 {pick.get('scraped_at', datetime.now().isoformat())}\n"
|
||||
body += "-" * 60 + "\n"
|
||||
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_webhook(new_picks: List[Dict], url: str) -> None:
|
||||
message = f"🚨 發現 {len(new_picks)} 條新的 Barron's 股票推薦!\n\n"
|
||||
for pick in new_picks[:5]:
|
||||
message += f"• {pick['title']}\n"
|
||||
if pick.get('link'):
|
||||
message += f" {pick['link']}\n"
|
||||
message += "\n"
|
||||
payload = {"text": message}
|
||||
requests.post(url, json=payload, timeout=10)
|
||||
|
||||
|
||||
def send_discord(new_picks: List[Dict], webhook: str) -> None:
|
||||
embed = {
|
||||
"title": "📈 Barron's 新股票推薦",
|
||||
"description": f"發現 {len(new_picks)} 條新推薦",
|
||||
"color": 0x00ff00,
|
||||
"fields": [],
|
||||
}
|
||||
for pick in new_picks[:5]:
|
||||
embed["fields"].append({
|
||||
"name": pick['title'][:256],
|
||||
"value": (pick.get('link') or '無連結')[:1024],
|
||||
"inline": False,
|
||||
})
|
||||
requests.post(webhook, json={"embeds": [embed]}, timeout=10)
|
||||
|
||||
|
||||
def build_test_pick() -> Dict:
|
||||
return {
|
||||
'title': f"[測試] Barron's 通知發送 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
'link': 'https://example.com/test',
|
||||
'scraped_at': datetime.now().isoformat(),
|
||||
'hash': hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:8],
|
||||
}
|
||||
|
Reference in New Issue
Block a user