mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -62,6 +62,8 @@ Thumbs.db
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
docs/
|
||||
local/
|
||||
|
||||
run.sh
|
||||
run.sh
|
||||
|
||||
verify_*.py
|
||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -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
107
README.md
@@ -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
|
||||
- [ ] 历史分析回测
|
||||
- [ ] 多策略支持
|
||||
- [ ] 港股/美股支持
|
||||
- [ ] 美股支持
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
|
||||
@@ -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
345
docs/full-guide.md
Normal 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
|
||||
- Gmail:smtp.gmail.com:587
|
||||
|
||||
### 自定义 Webhook
|
||||
|
||||
支持任意 POST JSON 的 Webhook,包括:
|
||||
- 钉钉机器人
|
||||
- Discord Webhook
|
||||
- Slack Webhook
|
||||
- Bark(iOS 推送)
|
||||
- 自建服务
|
||||
|
||||
设置 `CUSTOM_WEBHOOK_URLS`,多个用逗号分隔。
|
||||
|
||||
### Pushover(iOS/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)
|
||||
@@ -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 = '涨跌幅'
|
||||
|
||||
153
notification.py
153
notification.py
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user