mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: 自定义 Webhook 支持 (钉钉/Discord/Slack/Bark 等)
This commit is contained in:
@@ -79,6 +79,12 @@ SERPAPI_API_KEYS=your_serpapi_key_here
|
||||
# EMAIL_SENDER=your_email@qq.com
|
||||
# EMAIL_PASSWORD=your_email_auth_code
|
||||
# EMAIL_RECEIVERS=receiver@example.com # 可选,留空则发给自己
|
||||
#
|
||||
# 【方式五】自定义 Webhook(支持多个,逗号分隔)
|
||||
# 适用于:钉钉、Discord、Slack、Bark、自建服务等任意支持 POST JSON 的 Webhook
|
||||
# 系统会自动识别常见服务并使用对应格式
|
||||
#
|
||||
# CUSTOM_WEBHOOK_URLS=https://oapi.dingtalk.com/robot/send?access_token=xxx,https://hooks.slack.com/services/xxx
|
||||
|
||||
# 数据库路径
|
||||
DATABASE_PATH=./data/stock_analysis.db
|
||||
|
||||
2
.github/workflows/daily_analysis.yml
vendored
2
.github/workflows/daily_analysis.yml
vendored
@@ -73,6 +73,8 @@ jobs:
|
||||
EMAIL_SENDER: ${{ secrets.EMAIL_SENDER }}
|
||||
EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
|
||||
EMAIL_RECEIVERS: ${{ secrets.EMAIL_RECEIVERS }}
|
||||
# 方式五:自定义 Webhook(支持钉钉、Discord、Slack、Bark等,多个用逗号分隔)
|
||||
CUSTOM_WEBHOOK_URLS: ${{ secrets.CUSTOM_WEBHOOK_URLS }}
|
||||
|
||||
# 自选股列表 (从 secrets 或使用默认值)
|
||||
STOCK_LIST: ${{ secrets.STOCK_LIST || '600519' }}
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -8,9 +8,17 @@
|
||||
## [Unreleased]
|
||||
|
||||
### 计划中
|
||||
- 钉钉机器人支持
|
||||
- Web 管理界面
|
||||
|
||||
## [1.3.0] - 2026-01-12
|
||||
|
||||
### 新增
|
||||
- 🔗 自定义 Webhook 支持
|
||||
- 支持任意 POST JSON 的 Webhook 端点
|
||||
- 自动识别钉钉、Discord、Slack、Bark 等常见服务格式
|
||||
- 支持配置多个 Webhook(逗号分隔)
|
||||
- 通过 `CUSTOM_WEBHOOK_URLS` 环境变量配置
|
||||
|
||||
## [1.2.0] - 2026-01-11
|
||||
|
||||
### 新增
|
||||
|
||||
10
DEPLOY.md
10
DEPLOY.md
@@ -349,13 +349,21 @@ git push -u origin main
|
||||
| Secret 名称 | 说明 | 必填 |
|
||||
|------------|------|------|
|
||||
| `GEMINI_API_KEY` | Gemini AI API Key | ✅ |
|
||||
| `WECHAT_WEBHOOK_URL` | 企业微信机器人 Webhook | ✅ |
|
||||
| `WECHAT_WEBHOOK_URL` | 企业微信机器人 Webhook | 可选* |
|
||||
| `FEISHU_WEBHOOK_URL` | 飞书机器人 Webhook | 可选* |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | 可选* |
|
||||
| `TELEGRAM_CHAT_ID` | Telegram Chat ID | 可选* |
|
||||
| `EMAIL_SENDER` | 发件人邮箱 | 可选* |
|
||||
| `EMAIL_PASSWORD` | 邮箱授权码 | 可选* |
|
||||
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook(多个逗号分隔) | 可选* |
|
||||
| `STOCK_LIST` | 自选股列表,如 `600519,300750` | ✅ |
|
||||
| `TAVILY_API_KEYS` | Tavily 搜索 API Key | 推荐 |
|
||||
| `SERPAPI_API_KEYS` | SerpAPI Key | 可选 |
|
||||
| `TUSHARE_TOKEN` | Tushare Token | 可选 |
|
||||
| `GEMINI_MODEL` | 模型名称(默认 gemini-2.0-flash) | 可选 |
|
||||
|
||||
> *注:通知渠道至少配置一个,支持多渠道同时推送
|
||||
|
||||
#### 3. 验证 Workflow 文件
|
||||
|
||||
确保 `.github/workflows/daily_analysis.yml` 文件存在且已提交:
|
||||
|
||||
@@ -69,8 +69,11 @@
|
||||
| `EMAIL_SENDER` | 发件人邮箱(如 `xxx@qq.com`) | 可选 |
|
||||
| `EMAIL_PASSWORD` | 邮箱授权码(非登录密码) | 可选 |
|
||||
| `EMAIL_RECEIVERS` | 收件人邮箱(留空则发给自己) | 可选 |
|
||||
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook(多个用逗号分隔) | 可选 |
|
||||
|
||||
> *注:至少配置一个渠道,配置多个则同时推送到所有渠道
|
||||
>
|
||||
> 自定义 Webhook 支持:钉钉、Discord、Slack、Bark、自建服务等任意支持 POST JSON 的 Webhook
|
||||
|
||||
**其他配置**
|
||||
|
||||
@@ -231,10 +234,8 @@ daily_stock_analysis/
|
||||
- [x] 飞书机器人
|
||||
- [x] Telegram Bot
|
||||
- [x] 邮件通知(SMTP)
|
||||
- [ ] 钉钉机器人
|
||||
- [ ] Discord Webhook
|
||||
- [ ] Slack Webhook
|
||||
- [ ] iOS/Android 推送(Bark/Pushover)
|
||||
- [x] 自定义 Webhook(支持钉钉、Discord、Slack、Bark 等)
|
||||
- [ ] iOS/Android 推送(Pushover)
|
||||
|
||||
### 🤖 AI 模型支持
|
||||
- [x] Google Gemini(主力,免费额度)
|
||||
|
||||
@@ -70,6 +70,10 @@ class Config:
|
||||
email_password: Optional[str] = None # 邮箱密码/授权码
|
||||
email_receivers: List[str] = field(default_factory=list) # 收件人列表(留空则发给自己)
|
||||
|
||||
# 自定义 Webhook(支持多个,逗号分隔)
|
||||
# 适用于:钉钉、Discord、Slack、自建服务等任意支持 POST JSON 的 Webhook
|
||||
custom_webhook_urls: List[str] = field(default_factory=list)
|
||||
|
||||
# === 数据库配置 ===
|
||||
database_path: str = "./data/stock_analysis.db"
|
||||
|
||||
@@ -170,6 +174,7 @@ class Config:
|
||||
email_sender=os.getenv('EMAIL_SENDER'),
|
||||
email_password=os.getenv('EMAIL_PASSWORD'),
|
||||
email_receivers=[r.strip() for r in os.getenv('EMAIL_RECEIVERS', '').split(',') if r.strip()],
|
||||
custom_webhook_urls=[u.strip() for u in os.getenv('CUSTOM_WEBHOOK_URLS', '').split(',') if u.strip()],
|
||||
database_path=os.getenv('DATABASE_PATH', './data/stock_analysis.db'),
|
||||
log_dir=os.getenv('LOG_DIR', './logs'),
|
||||
log_level=os.getenv('LOG_LEVEL', 'INFO'),
|
||||
|
||||
121
notification.py
121
notification.py
@@ -38,6 +38,7 @@ class NotificationChannel(Enum):
|
||||
FEISHU = "feishu" # 飞书
|
||||
TELEGRAM = "telegram" # Telegram
|
||||
EMAIL = "email" # 邮件
|
||||
CUSTOM = "custom" # 自定义 Webhook
|
||||
UNKNOWN = "unknown" # 未知
|
||||
|
||||
|
||||
@@ -80,6 +81,7 @@ class ChannelDetector:
|
||||
NotificationChannel.FEISHU: "飞书",
|
||||
NotificationChannel.TELEGRAM: "Telegram",
|
||||
NotificationChannel.EMAIL: "邮件",
|
||||
NotificationChannel.CUSTOM: "自定义Webhook",
|
||||
NotificationChannel.UNKNOWN: "未知渠道",
|
||||
}
|
||||
return names.get(channel, "未知渠道")
|
||||
@@ -128,6 +130,9 @@ class NotificationService:
|
||||
'receivers': config.email_receivers or ([config.email_sender] if config.email_sender else []),
|
||||
}
|
||||
|
||||
# 自定义 Webhook 配置
|
||||
self._custom_webhook_urls = getattr(config, 'custom_webhook_urls', []) or []
|
||||
|
||||
# 检测所有已配置的渠道
|
||||
self._available_channels = self._detect_all_channels()
|
||||
|
||||
@@ -162,6 +167,10 @@ class NotificationService:
|
||||
if self._is_email_configured():
|
||||
channels.append(NotificationChannel.EMAIL)
|
||||
|
||||
# 自定义 Webhook
|
||||
if self._custom_webhook_urls:
|
||||
channels.append(NotificationChannel.CUSTOM)
|
||||
|
||||
return channels
|
||||
|
||||
def _is_telegram_configured(self) -> bool:
|
||||
@@ -1302,6 +1311,116 @@ class NotificationService:
|
||||
|
||||
return result
|
||||
|
||||
def send_to_custom(self, content: str) -> bool:
|
||||
"""
|
||||
推送消息到自定义 Webhook
|
||||
|
||||
支持任意接受 POST JSON 的 Webhook 端点
|
||||
默认发送格式:{"text": "消息内容", "content": "消息内容"}
|
||||
|
||||
适用于:
|
||||
- 钉钉机器人
|
||||
- Discord Webhook
|
||||
- Slack Incoming Webhook
|
||||
- 自建通知服务
|
||||
- 其他支持 POST JSON 的服务
|
||||
|
||||
Args:
|
||||
content: 消息内容(Markdown 格式)
|
||||
|
||||
Returns:
|
||||
是否至少有一个 Webhook 发送成功
|
||||
"""
|
||||
if not self._custom_webhook_urls:
|
||||
logger.warning("未配置自定义 Webhook,跳过推送")
|
||||
return False
|
||||
|
||||
success_count = 0
|
||||
|
||||
for i, url in enumerate(self._custom_webhook_urls):
|
||||
try:
|
||||
# 通用 JSON 格式,兼容大多数 Webhook
|
||||
# 钉钉格式: {"msgtype": "text", "text": {"content": "xxx"}}
|
||||
# Slack 格式: {"text": "xxx"}
|
||||
# Discord 格式: {"content": "xxx"}
|
||||
|
||||
# 检测 URL 类型并构造对应格式
|
||||
payload = self._build_custom_webhook_payload(url, content)
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'StockAnalysis/1.0'
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"自定义 Webhook {i+1} 推送成功")
|
||||
success_count += 1
|
||||
else:
|
||||
logger.error(f"自定义 Webhook {i+1} 推送失败: HTTP {response.status_code}")
|
||||
logger.debug(f"响应内容: {response.text[:200]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"自定义 Webhook {i+1} 推送异常: {e}")
|
||||
|
||||
logger.info(f"自定义 Webhook 推送完成:成功 {success_count}/{len(self._custom_webhook_urls)}")
|
||||
return success_count > 0
|
||||
|
||||
def _build_custom_webhook_payload(self, url: str, content: str) -> dict:
|
||||
"""
|
||||
根据 URL 构建对应的 Webhook payload
|
||||
|
||||
自动识别常见服务并使用对应格式
|
||||
"""
|
||||
url_lower = url.lower()
|
||||
|
||||
# 钉钉机器人
|
||||
if 'dingtalk' in url_lower or 'oapi.dingtalk.com' in url_lower:
|
||||
return {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": "股票分析报告",
|
||||
"text": content
|
||||
}
|
||||
}
|
||||
|
||||
# Discord Webhook
|
||||
if 'discord.com/api/webhooks' in url_lower or 'discordapp.com/api/webhooks' in url_lower:
|
||||
# Discord 限制 2000 字符
|
||||
truncated = content[:1900] + "..." if len(content) > 1900 else content
|
||||
return {
|
||||
"content": truncated
|
||||
}
|
||||
|
||||
# Slack Incoming Webhook
|
||||
if 'hooks.slack.com' in url_lower:
|
||||
return {
|
||||
"text": content,
|
||||
"mrkdwn": True
|
||||
}
|
||||
|
||||
# Bark (iOS 推送)
|
||||
if 'api.day.app' in url_lower:
|
||||
return {
|
||||
"title": "股票分析报告",
|
||||
"body": content[:4000], # Bark 限制
|
||||
"group": "stock"
|
||||
}
|
||||
|
||||
# 通用格式(兼容大多数服务)
|
||||
return {
|
||||
"text": content,
|
||||
"content": content,
|
||||
"message": content,
|
||||
"body": content
|
||||
}
|
||||
|
||||
def send(self, content: str) -> bool:
|
||||
"""
|
||||
统一发送接口 - 向所有已配置的渠道发送
|
||||
@@ -1335,6 +1454,8 @@ class NotificationService:
|
||||
result = self.send_to_telegram(content)
|
||||
elif channel == NotificationChannel.EMAIL:
|
||||
result = self.send_to_email(content)
|
||||
elif channel == NotificationChannel.CUSTOM:
|
||||
result = self.send_to_custom(content)
|
||||
else:
|
||||
logger.warning(f"不支持的通知渠道: {channel}")
|
||||
result = False
|
||||
|
||||
Reference in New Issue
Block a user