- 新增 app/crawlers/openinsider.py,來源 http://openinsider.com/search?q={symbol} - 支援多標的:以 SYMBOLS=PLTR,NVDA,... 同時追多檔(或使用 SYMBOL 單一) - runner: 多實例排程與啟動;/check 會依序觸發全部爬蟲 - API: /info、/stats、/check、/notify_test 支援多爬蟲回應 - config/base: 新增 RUN_DAILY_AT 每日固定時間;未設定則用 CHECK_INTERVAL - notifications: 新增 send_custom_email、send_text_webhook、send_text_discord - README 與 .env.template 更新;.env 改為 CRAWLER_TYPE=openinsider - 移除 quiver_insiders 爬蟲與相關設定 BREAKING CHANGE: 不再支援 CRAWLER_TYPE=quiver_insiders;請改用 openinsider。
121 lines
3.7 KiB
Python
121 lines
3.7 KiB
Python
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_custom_email(subject: str, body: str, cfg: EmailConfig) -> None:
|
|
msg = MIMEMultipart()
|
|
msg['From'] = cfg.from_email
|
|
msg['To'] = cfg.to_email
|
|
msg['Subject'] = subject
|
|
msg.attach(MIMEText(body, '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_text_webhook(message: str, url: str) -> None:
|
|
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 send_text_discord(title: str, description: str, lines: List[str], webhook: str) -> None:
|
|
embed = {
|
|
"title": title,
|
|
"description": description,
|
|
"color": 0x00ff00,
|
|
"fields": [],
|
|
}
|
|
for line in lines[:10]:
|
|
embed["fields"].append({
|
|
"name": line[:256],
|
|
"value": "\u200b",
|
|
"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],
|
|
}
|