docs: 精简 README,新增完整配置指南

feat: 修复 HK 股票代码识别 & 增强稳定性

- README 精简至 236 行,高级配置移至 docs/full-guide.md
- 修复 HK 股票代码识别(支持 hk1810 等 1-5 位格式)
- AkShare API 调用添加重试机制和失败缓存 (#62)
- 钉钉 Webhook 支持 20KB 限制分块发送 (#61)

Closes #61, #62
This commit is contained in:
zhulinsen
2026-01-17 17:36:08 +08:00
parent 8edabfb532
commit e88d2248fe
7 changed files with 605 additions and 154 deletions

6
.gitignore vendored
View File

@@ -62,6 +62,8 @@ Thumbs.db
.coverage
htmlcov/
docs/
local/
run.sh
run.sh
verify_*.py

View File

@@ -10,6 +10,35 @@
### 计划中
- Web 管理界面
## [1.4.0] - 2026-01-17
### 新增
- 📱 Pushover 推送支持PR #26
- 支持 iOS/Android 跨平台推送
- 通过 `PUSHOVER_USER_KEY``PUSHOVER_API_TOKEN` 配置
- 🔍 博查搜索 API 集成PR #27
- 中文搜索优化,支持 AI 摘要
- 通过 `BOCHA_API_KEYS` 配置
- 📊 Efinance 数据源支持PR #59
- 新增 efinance 作为数据源选项
- 🇭🇰 港股支持PR #17
- 支持 5 位代码或 HK 前缀(如 `hk00700``hk1810`
### 修复
- 🔧 飞书 Markdown 渲染优化PR #34
- 使用交互卡片和格式化器修复渲染问题
- ♻️ 股票列表热重载PR #42 修复)
- 分析前自动重载 `STOCK_LIST` 配置
- 🐛 钉钉 Webhook 20KB 限制处理
- 长消息自动分块发送,避免被截断
- 🔄 AkShare API 重试机制增强
- 添加失败缓存,避免重复请求失败接口
### 改进
- 📝 README 精简优化
- 高级配置移至 `docs/full-guide.md`
## [1.3.0] - 2026-01-12
### 新增

107
README.md
View File

@@ -70,23 +70,11 @@
| `EMAIL_SENDER` | 发件人邮箱(如 `xxx@qq.com` | 可选 |
| `EMAIL_PASSWORD` | 邮箱授权码(非登录密码) | 可选 |
| `EMAIL_RECEIVERS` | 收件人邮箱(多个用逗号分隔,留空则发给自己) | 可选 |
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook多个用逗号分隔 | 可选 | | 可选 |
| `FEISHU_APP_ID` | 飞书应用ID需要去[开发者后台](https://open.feishu.cn/app)创建应用,步骤参考[这里](https://blog.csdn.net/qq_38423105/article/details/149316776) | 可选 |
| `FEISHU_APP_SECRET` | 飞书应用APP_SECRET | 可选 |
| `FEISHU_FOLDER_TOKEN` | 飞书文档云盘文件夹Key(地址栏 folder 后面参数) | 可选 |
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook支持钉钉等,多个用逗号分隔) | 可选 |
> *注:至少配置一个渠道,配置多个则同时推送到所有渠道
>
> 自定义 Webhook 支持钉钉、Discord、Slack、Bark、自建服务等任意支持 POST JSON 的 Webhook
>
> 通过飞书应用创建的飞书文档,里面的内容不会出现已截断的情况。应用创建好后需要执行以下操作:
>
> 1.Github 配置对应 Secret
>
> 2.创建群组,在群组设置->群机器人,将创建的应用添加到群组内,算上飞书 Webhook此时群组应该会有两个机器人
>
> 3.点击飞书云盘文件夹的“...”,将群组添加为协作者,权限设置为可管理
> *注:至少配置一个渠道,配置多个则同时推送
>
> 📖 更多配置Pushover 手机推送、飞书云文档等)请参考 [完整配置指南](docs/full-guide.md)
**其他配置**
@@ -110,39 +98,9 @@
默认每个工作日 **18:00北京时间** 自动执行
### 方式二:本地运行
### 方式二:本地运行 / Docker 部署
```bash
# 克隆仓库
git clone https://github.com/ZhuLinsen/daily_stock_analysis.git
cd daily_stock_analysis
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
cp .env.example .env
vim .env # 填入你的 API Key
# 运行
python main.py # 完整分析
python main.py --market-review # 仅大盘复盘
python main.py --schedule # 定时任务模式
```
### 方式三Docker 部署
```bash
# 配置环境变量
cp .env.example .env
vim .env
# 一键启动
docker-compose up -d
# 查看日志
docker-compose logs -f
```
> 📖 本地运行、Docker 部署详细步骤请参考 [完整配置指南](docs/full-guide.md)
## 📱 推送效果
@@ -186,41 +144,7 @@ docker-compose logs -f
## ⚙️ 配置说明
### 环境变量
```bash
# === 必填 ===
GEMINI_API_KEY=your_gemini_key # Gemini AI
WECHAT_WEBHOOK_URL=https://qyapi... # 企业微信机器人
STOCK_LIST=600519,300750,002594 # 自选股列表
# === 推荐 ===
TAVILY_API_KEYS=your_tavily_key # Tavily搜索
GEMINI_MODEL=gemini-3-flash-preview # 主模型
GEMINI_MODEL_FALLBACK=gemini-2.5-flash # 备选模型
# === 可选 ===
BOCHA_API_KEYS=your_bocha_key # 博查搜索中文优化支持AI摘要多个key用逗号分隔
TUSHARE_TOKEN=your_token # Tushare数据源
SERPAPI_API_KEYS=your_serpapi_key # 备用搜索
```
### 定时配置GitHub Actions
编辑 `.github/workflows/daily_analysis.yml`:
```yaml
schedule:
# UTC 时间,北京时间 = UTC + 8
- cron: '0 10 * * 1-5' # 周一到周五 18:00北京时间
```
| 北京时间 | UTC cron |
|---------|----------|
| 09:30 | `'30 1 * * 1-5'` |
| 15:00 | `'0 7 * * 1-5'` |
| 18:00 | `'0 10 * * 1-5'` |
> 📖 完整环境变量、定时任务配置请参考 [完整配置指南](docs/full-guide.md)
## 📁 项目结构
```
@@ -257,14 +181,7 @@ daily_stock_analysis/
### 🤖 AI 模型支持
- [x] Google Gemini主力免费额度
- [x] OpenAI 兼容 API支持以下模型
- [x] OpenAI GPT-4/4o
- [x] DeepSeek
- [x] 通义千问
- [x] Moonshot月之暗面
- [x] 智谱 GLM
- [ ] Claude
- [ ] 文心一言
- [x] OpenAI 兼容 API支持 GPT-4/DeepSeek/通义千问/Claude/文心一言 等
- [x] 本地模型Ollama
### 📊 数据源扩展
@@ -272,20 +189,16 @@ daily_stock_analysis/
- [x] Tushare Pro
- [x] Baostock
- [x] YFinance
- [ ] 东方财富 API
- [ ] 同花顺 API
- [ ] 新浪财经
### 🎯 功能增强
- [x] 决策仪表盘
- [x] 大盘复盘
- [x] 定时推送
- [x] GitHub Actions
- [x] 港股支持
- [ ] Web 管理界面
- [ ] 自选股动态管理 API
- [ ] 历史分析回测
- [ ] 多策略支持
- [ ] 港股/美股支持
- [ ] 美股支持
## 🤝 贡献

View File

@@ -220,7 +220,7 @@ def _is_hk_code(stock_code: str) -> bool:
港股代码规则:
- 5位数字代码'00700' (腾讯控股)
- 部分港股代码可能带有前缀,如 'hk00700'
- 部分港股代码可能带有前缀,如 'hk00700', 'hk1810'
Args:
stock_code: 股票代码
@@ -228,9 +228,13 @@ def _is_hk_code(stock_code: str) -> bool:
Returns:
True 表示是港股代码False 表示不是港股代码
"""
# 去除可能的 'hk' 前缀
code = stock_code.lower().replace('hk', '')
# 港股代码为5位数字
# 去除可能的 'hk' 前缀并检查是否为纯数字
code = stock_code.lower()
if code.startswith('hk'):
# 带 hk 前缀的一定是港股去掉前缀后应为纯数字1-5位
numeric_part = code[2:]
return numeric_part.isdigit() and 1 <= len(numeric_part) <= 5
# 无前缀时5位纯数字才视为港股避免误判 A 股代码)
return code.isdigit() and len(code) == 5
@@ -584,22 +588,38 @@ class AkshareFetcher(BaseFetcher):
df = _realtime_cache['data']
logger.debug(f"[缓存命中] 使用缓存的A股实时行情数据")
else:
# 防封禁策略
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info(f"[API调用] ak.stock_zh_a_spot_em() 获取A股实时行情...")
import time as _time
api_start = _time.time()
df = ak.stock_zh_a_spot_em()
api_elapsed = _time.time() - api_start
logger.info(f"[API返回] ak.stock_zh_a_spot_em 成功: 返回 {len(df)} 只股票, 耗时 {api_elapsed:.2f}s")
# 更新缓存
last_error: Optional[Exception] = None
df = None
for attempt in range(1, 3):
try:
# 防封禁策略
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info(f"[API调用] ak.stock_zh_a_spot_em() 获取A股实时行情... (attempt {attempt}/2)")
import time as _time
api_start = _time.time()
df = ak.stock_zh_a_spot_em()
api_elapsed = _time.time() - api_start
logger.info(f"[API返回] ak.stock_zh_a_spot_em 成功: 返回 {len(df)} 只股票, 耗时 {api_elapsed:.2f}s")
break
except Exception as e:
last_error = e
logger.warning(f"[API错误] ak.stock_zh_a_spot_em 获取失败 (attempt {attempt}/2): {e}")
time.sleep(min(2 ** attempt, 5))
# 更新缓存:成功缓存数据;失败也缓存空数据,避免同一轮任务对同一接口反复请求
if df is None:
logger.error(f"[API错误] ak.stock_zh_a_spot_em 最终失败: {last_error}")
df = pd.DataFrame()
_realtime_cache['data'] = df
_realtime_cache['timestamp'] = current_time
if df is None or df.empty:
logger.warning(f"[实时行情] A股实时行情数据为空跳过 {stock_code}")
return None
# 查找指定股票
row = df[df['代码'] == stock_code]
@@ -668,22 +688,37 @@ class AkshareFetcher(BaseFetcher):
df = _etf_realtime_cache['data']
logger.debug(f"[缓存命中] 使用缓存的ETF实时行情数据")
else:
# 防封禁策略
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info(f"[API调用] ak.fund_etf_spot_em() 获取ETF实时行情...")
import time as _time
api_start = _time.time()
df = ak.fund_etf_spot_em()
api_elapsed = _time.time() - api_start
logger.info(f"[API返回] ak.fund_etf_spot_em 成功: 返回 {len(df)} 只ETF, 耗时 {api_elapsed:.2f}s")
# 更新缓存
last_error: Optional[Exception] = None
df = None
for attempt in range(1, 3):
try:
# 防封禁策略
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info(f"[API调用] ak.fund_etf_spot_em() 获取ETF实时行情... (attempt {attempt}/2)")
import time as _time
api_start = _time.time()
df = ak.fund_etf_spot_em()
api_elapsed = _time.time() - api_start
logger.info(f"[API返回] ak.fund_etf_spot_em 成功: 返回 {len(df)} 只ETF, 耗时 {api_elapsed:.2f}s")
break
except Exception as e:
last_error = e
logger.warning(f"[API错误] ak.fund_etf_spot_em 获取失败 (attempt {attempt}/2): {e}")
time.sleep(min(2 ** attempt, 5))
if df is None:
logger.error(f"[API错误] ak.fund_etf_spot_em 最终失败: {last_error}")
df = pd.DataFrame()
_etf_realtime_cache['data'] = df
_etf_realtime_cache['timestamp'] = current_time
if df is None or df.empty:
logger.warning(f"[实时行情] ETF实时行情数据为空跳过 {stock_code}")
return None
# 查找指定 ETF
row = df[df['代码'] == stock_code]

345
docs/full-guide.md Normal file
View File

@@ -0,0 +1,345 @@
# 📖 完整配置与部署指南
本文档包含 A股智能分析系统的完整配置说明适合需要高级功能或特殊部署方式的用户。
> 💡 快速上手请参考 [README.md](../README.md),本文档为进阶配置。
## 📑 目录
- [环境变量完整列表](#环境变量完整列表)
- [Docker 部署](#docker-部署)
- [本地运行详细配置](#本地运行详细配置)
- [定时任务配置](#定时任务配置)
- [通知渠道详细配置](#通知渠道详细配置)
- [数据源配置](#数据源配置)
- [高级功能](#高级功能)
---
## 环境变量完整列表
### AI 模型配置
| 变量名 | 说明 | 默认值 | 必填 |
|--------|------|--------|:----:|
| `GEMINI_API_KEY` | Google Gemini API Key | - | ✅* |
| `GEMINI_MODEL` | 主模型名称 | `gemini-3-flash-preview` | 否 |
| `GEMINI_MODEL_FALLBACK` | 备选模型 | `gemini-2.5-flash` | 否 |
| `OPENAI_API_KEY` | OpenAI 兼容 API Key | - | 可选 |
| `OPENAI_BASE_URL` | OpenAI 兼容 API 地址 | - | 可选 |
| `OPENAI_MODEL` | OpenAI 模型名称 | `gpt-4o` | 可选 |
> *注:`GEMINI_API_KEY` 和 `OPENAI_API_KEY` 至少配置一个
### 通知渠道配置
| 变量名 | 说明 | 必填 |
|--------|------|:----:|
| `WECHAT_WEBHOOK_URL` | 企业微信机器人 Webhook URL | 可选 |
| `FEISHU_WEBHOOK_URL` | 飞书机器人 Webhook URL | 可选 |
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | 可选 |
| `TELEGRAM_CHAT_ID` | Telegram Chat ID | 可选 |
| `EMAIL_SENDER` | 发件人邮箱 | 可选 |
| `EMAIL_PASSWORD` | 邮箱授权码(非登录密码) | 可选 |
| `EMAIL_RECEIVERS` | 收件人邮箱(逗号分隔,留空发给自己) | 可选 |
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook逗号分隔 | 可选 |
| `PUSHOVER_USER_KEY` | Pushover 用户 Key | 可选 |
| `PUSHOVER_API_TOKEN` | Pushover API Token | 可选 |
#### 飞书云文档配置(可选,解决消息截断问题)
| 变量名 | 说明 | 必填 |
|--------|------|:----:|
| `FEISHU_APP_ID` | 飞书应用 ID | 可选 |
| `FEISHU_APP_SECRET` | 飞书应用 Secret | 可选 |
| `FEISHU_FOLDER_TOKEN` | 飞书云盘文件夹 Token | 可选 |
> 飞书云文档配置步骤:
> 1. 在 [飞书开发者后台](https://open.feishu.cn/app) 创建应用
> 2. 配置 GitHub Secrets
> 3. 创建群组并添加应用机器人
> 4. 在云盘文件夹中添加群组为协作者(可管理权限)
### 搜索服务配置
| 变量名 | 说明 | 必填 |
|--------|------|:----:|
| `TAVILY_API_KEYS` | Tavily 搜索 API Key推荐 | 推荐 |
| `BOCHA_API_KEYS` | 博查搜索 API Key中文优化 | 可选 |
| `SERPAPI_API_KEYS` | SerpAPI 备用搜索 | 可选 |
### 数据源配置
| 变量名 | 说明 | 必填 |
|--------|------|:----:|
| `TUSHARE_TOKEN` | Tushare Pro Token | 可选 |
### 其他配置
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `STOCK_LIST` | 自选股代码(逗号分隔) | - |
| `MAX_WORKERS` | 并发线程数 | `3` |
| `MARKET_REVIEW_ENABLED` | 启用大盘复盘 | `true` |
| `SCHEDULE_ENABLED` | 启用定时任务 | `false` |
| `SCHEDULE_TIME` | 定时执行时间 | `18:00` |
| `LOG_DIR` | 日志目录 | `./logs` |
---
## Docker 部署
### 快速启动
```bash
# 1. 克隆仓库
git clone https://github.com/ZhuLinsen/daily_stock_analysis.git
cd daily_stock_analysis
# 2. 配置环境变量
cp .env.example .env
vim .env # 填入 API Key 和配置
# 3. 启动容器
docker-compose up -d
# 4. 查看日志
docker-compose logs -f
```
### Docker Compose 配置
`docker-compose.yml` 已配置好定时任务模式:
```yaml
version: '3.8'
services:
stock-analysis:
build: .
environment:
- TZ=Asia/Shanghai
env_file:
- .env
volumes:
- ./data:/app/data # 数据持久化
- ./logs:/app/logs # 日志持久化
- ./reports:/app/reports # 报告持久化
restart: unless-stopped
```
### 手动构建镜像
```bash
docker build -t stock-analysis .
docker run -d --env-file .env -v ./data:/app/data stock-analysis
```
---
## 本地运行详细配置
### 安装依赖
```bash
# Python 3.10+ 推荐
pip install -r requirements.txt
# 或使用 conda
conda create -n stock python=3.10
conda activate stock
pip install -r requirements.txt
```
### 命令行参数
```bash
python main.py # 完整分析(个股 + 大盘复盘)
python main.py --market-review # 仅大盘复盘
python main.py --no-market-review # 仅个股分析
python main.py --stocks 600519,300750 # 指定股票
python main.py --dry-run # 仅获取数据,不 AI 分析
python main.py --no-notify # 不发送推送
python main.py --schedule # 定时任务模式
python main.py --debug # 调试模式(详细日志)
python main.py --workers 5 # 指定并发数
```
---
## 定时任务配置
### GitHub Actions 定时
编辑 `.github/workflows/daily_analysis.yml`:
```yaml
schedule:
# UTC 时间,北京时间 = UTC + 8
- cron: '0 10 * * 1-5' # 周一到周五 18:00北京时间
```
常用时间对照:
| 北京时间 | UTC cron 表达式 |
|---------|----------------|
| 09:30 | `'30 1 * * 1-5'` |
| 12:00 | `'0 4 * * 1-5'` |
| 15:00 | `'0 7 * * 1-5'` |
| 18:00 | `'0 10 * * 1-5'` |
| 21:00 | `'0 13 * * 1-5'` |
### 本地定时任务
```bash
# 启动定时模式(默认 18:00 执行)
python main.py --schedule
# 或使用 crontab
crontab -e
# 添加0 18 * * 1-5 cd /path/to/project && python main.py
```
---
## 通知渠道详细配置
### 企业微信
1. 在企业微信群聊中添加"群机器人"
2. 复制 Webhook URL
3. 设置 `WECHAT_WEBHOOK_URL`
### 飞书
1. 在飞书群聊中添加"自定义机器人"
2. 复制 Webhook URL
3. 设置 `FEISHU_WEBHOOK_URL`
### Telegram
1.@BotFather 对话创建 Bot
2. 获取 Bot Token
3. 获取 Chat ID可通过 @userinfobot
4. 设置 `TELEGRAM_BOT_TOKEN``TELEGRAM_CHAT_ID`
### 邮件
1. 开启邮箱的 SMTP 服务
2. 获取授权码(非登录密码)
3. 设置 `EMAIL_SENDER``EMAIL_PASSWORD``EMAIL_RECEIVERS`
支持的邮箱:
- QQ 邮箱smtp.qq.com:465
- 163 邮箱smtp.163.com:465
- Gmailsmtp.gmail.com:587
### 自定义 Webhook
支持任意 POST JSON 的 Webhook包括
- 钉钉机器人
- Discord Webhook
- Slack Webhook
- BarkiOS 推送)
- 自建服务
设置 `CUSTOM_WEBHOOK_URLS`,多个用逗号分隔。
### PushoveriOS/Android 推送)
[Pushover](https://pushover.net/) 是一个跨平台的推送服务,支持 iOS 和 Android。
1. 注册 Pushover 账号并下载 App
2. 在 [Pushover Dashboard](https://pushover.net/) 获取 User Key
3. 创建 Application 获取 API Token
4. 配置环境变量:
```bash
PUSHOVER_USER_KEY=your_user_key
PUSHOVER_API_TOKEN=your_api_token
```
特点:
- 支持 iOS/Android 双平台
- 支持通知优先级和声音设置
- 免费额度足够个人使用(每月 10,000 条)
- 消息可保留 7 天
---
## 数据源配置
系统默认使用 AkShare免费也支持其他数据源
### AkShare默认
- 免费,无需配置
- 数据来源:东方财富爬虫
### Tushare Pro
- 需要注册获取 Token
- 更稳定,数据更全
- 设置 `TUSHARE_TOKEN`
### Baostock
- 免费,无需配置
- 作为备用数据源
### YFinance
- 免费,无需配置
- 支持美股/港股数据
---
## 高级功能
### 港股支持
使用 `hk` 前缀指定港股代码:
```bash
STOCK_LIST=600519,hk00700,hk01810
```
### 多模型切换
配置多个模型,系统自动切换:
```bash
# Gemini主力
GEMINI_API_KEY=xxx
GEMINI_MODEL=gemini-3-flash-preview
# OpenAI 兼容(备选)
OPENAI_API_KEY=xxx
OPENAI_BASE_URL=https://api.deepseek.com/v1
OPENAI_MODEL=deepseek-chat
```
### 调试模式
```bash
python main.py --debug
```
日志文件位置:
- 常规日志:`logs/stock_analysis_YYYYMMDD.log`
- 调试日志:`logs/stock_analysis_debug_YYYYMMDD.log`
---
## 常见问题
### Q: 推送消息被截断?
A: 企业微信/飞书有消息长度限制,系统已自动分段发送。如需完整内容,可配置飞书云文档功能。
### Q: 数据获取失败?
A: AkShare 使用爬虫机制,可能被临时限流。系统已配置重试机制,一般等待几分钟后重试即可。
### Q: 如何添加自选股?
A: 修改 `STOCK_LIST` 环境变量,多个代码用逗号分隔。
### Q: GitHub Actions 没有执行?
A: 检查是否启用了 Actions以及 cron 表达式是否正确(注意是 UTC 时间)。
---
更多问题请 [提交 Issue](https://github.com/ZhuLinsen/daily_stock_analysis/issues)

View File

@@ -11,6 +11,7 @@
"""
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, Dict, Any, List
@@ -131,6 +132,19 @@ class MarketAnalyzer:
# self._get_north_flow(overview)
return overview
def _call_akshare_with_retry(self, fn, name: str, attempts: int = 2):
last_error: Optional[Exception] = None
for attempt in range(1, attempts + 1):
try:
return fn()
except Exception as e:
last_error = e
logger.warning(f"[大盘] {name} 获取失败 (attempt {attempt}/{attempts}): {e}")
if attempt < attempts:
time.sleep(min(2 ** attempt, 5))
logger.error(f"[大盘] {name} 最终失败: {last_error}")
return None
def _get_main_indices(self) -> List[MarketIndex]:
"""获取主要指数实时行情"""
@@ -140,7 +154,7 @@ class MarketAnalyzer:
logger.info("[大盘] 获取主要指数实时行情...")
# 使用 akshare 获取指数行情(新浪财经接口,包含深市指数)
df = ak.stock_zh_index_spot_sina()
df = self._call_akshare_with_retry(ak.stock_zh_index_spot_sina, "指数行情", attempts=2)
if df is not None and not df.empty:
for code, name in self.MAIN_INDICES.items():
@@ -183,7 +197,7 @@ class MarketAnalyzer:
logger.info("[大盘] 获取市场涨跌统计...")
# 获取全部A股实时行情
df = ak.stock_zh_a_spot_em()
df = self._call_akshare_with_retry(ak.stock_zh_a_spot_em, "A股实时行情", attempts=2)
if df is not None and not df.empty:
# 涨跌统计
@@ -217,7 +231,7 @@ class MarketAnalyzer:
logger.info("[大盘] 获取板块涨跌榜...")
# 获取行业板块行情
df = ak.stock_board_industry_name_em()
df = self._call_akshare_with_retry(ak.stock_board_industry_name_em, "行业板块行情", attempts=2)
if df is not None and not df.empty:
change_col = '涨跌幅'

View File

@@ -2002,36 +2002,149 @@ class NotificationService:
# Slack 格式: {"text": "xxx"}
# Discord 格式: {"content": "xxx"}
# 检测 URL 类型并构造对应格式
# 钉钉机器人对 body 有字节上限(约 20000 bytes超长需要分批发送
if self._is_dingtalk_webhook(url):
if self._send_dingtalk_chunked(url, content, max_bytes=20000):
logger.info(f"自定义 Webhook {i+1}(钉钉)推送成功")
success_count += 1
else:
logger.error(f"自定义 Webhook {i+1}(钉钉)推送失败")
continue
# 其他 Webhook单次发送
payload = self._build_custom_webhook_payload(url, content)
headers = {
'Content-Type': 'application/json',
'User-Agent': 'StockAnalysis/1.0'
}
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
headers_with_charset = dict(headers)
headers_with_charset['Content-Type'] = 'application/json; charset=utf-8'
response = requests.post(
url,
data=body,
headers=headers_with_charset,
timeout=30
)
if response.status_code == 200:
if self._post_custom_webhook(url, payload, timeout=30):
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]}")
logger.error(f"自定义 Webhook {i+1} 推送失败")
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
@staticmethod
def _is_dingtalk_webhook(url: str) -> bool:
url_lower = (url or "").lower()
return 'dingtalk' in url_lower or 'oapi.dingtalk.com' in url_lower
def _post_custom_webhook(self, url: str, payload: dict, timeout: int = 30) -> bool:
headers = {
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': 'StockAnalysis/1.0',
}
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
response = requests.post(url, data=body, headers=headers, timeout=timeout)
if response.status_code == 200:
return True
logger.error(f"自定义 Webhook 推送失败: HTTP {response.status_code}")
logger.debug(f"响应内容: {response.text[:200]}")
return False
def _chunk_markdown_by_bytes(self, content: str, max_bytes: int) -> List[str]:
def get_bytes(s: str) -> int:
return len(s.encode('utf-8'))
def split_by_bytes(text: str, limit: int) -> List[str]:
parts: List[str] = []
remaining = text
while remaining:
part = self._truncate_to_bytes(remaining, limit)
if not part:
break
parts.append(part)
remaining = remaining[len(part):]
return parts
# 优先按分隔线/标题分割,保证分页自然
if "\n---\n" in content:
sections = content.split("\n---\n")
separator = "\n---\n"
elif "\n### " in content:
parts = content.split("\n### ")
sections = [parts[0]] + [f"### {p}" for p in parts[1:]]
separator = "\n"
else:
# fallback按行拼接
sections = content.split("\n")
separator = "\n"
chunks: List[str] = []
current_chunk: List[str] = []
current_bytes = 0
sep_bytes = get_bytes(separator)
for section in sections:
section_bytes = get_bytes(section)
extra = sep_bytes if current_chunk else 0
# 单段超长:截断
if section_bytes + extra > max_bytes:
if current_chunk:
chunks.append(separator.join(current_chunk))
current_chunk = []
current_bytes = 0
# 无法按结构拆分时,按字节强制拆分,避免整段被截断丢失
for part in split_by_bytes(section, max(200, max_bytes - 200)):
chunks.append(part)
continue
if current_bytes + section_bytes + extra > max_bytes:
chunks.append(separator.join(current_chunk))
current_chunk = [section]
current_bytes = section_bytes
else:
if current_chunk:
current_bytes += sep_bytes
current_chunk.append(section)
current_bytes += section_bytes
if current_chunk:
chunks.append(separator.join(current_chunk))
# 移除空块
return [c for c in (c.strip() for c in chunks) if c]
def _send_dingtalk_chunked(self, url: str, content: str, max_bytes: int = 20000) -> bool:
import time as _time
# 为 payload 开销预留空间,避免 body 超限
budget = max(1000, max_bytes - 1500)
chunks = self._chunk_markdown_by_bytes(content, budget)
if not chunks:
return False
total = len(chunks)
ok = 0
for idx, chunk in enumerate(chunks):
marker = f"\n\n📄 *({idx+1}/{total})*" if total > 1 else ""
payload = {
"msgtype": "markdown",
"markdown": {
"title": "股票分析报告",
"text": chunk + marker,
},
}
# 如果仍超限(极端情况下),再按字节硬截断一次
body_bytes = len(json.dumps(payload, ensure_ascii=False).encode('utf-8'))
if body_bytes > max_bytes:
hard_budget = max(200, budget - (body_bytes - max_bytes) - 200)
payload["markdown"]["text"] = self._truncate_to_bytes(payload["markdown"]["text"], hard_budget)
if self._post_custom_webhook(url, payload, timeout=30):
ok += 1
else:
logger.error(f"钉钉分批发送失败: 第 {idx+1}/{total}")
if idx < total - 1:
_time.sleep(1)
return ok == total
def _build_custom_webhook_payload(self, url: str, content: str) -> dict:
"""