From e88d2248fe5fec8a47a1977be64e3d33cc01f95b Mon Sep 17 00:00:00 2001 From: zhulinsen Date: Sat, 17 Jan 2026 17:36:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=B2=BE=E7=AE=80=20README=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=8C=E6=95=B4=E9=85=8D=E7=BD=AE=E6=8C=87?= =?UTF-8?q?=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: 修复 HK 股票代码识别 & 增强稳定性 - README 精简至 236 行,高级配置移至 docs/full-guide.md - 修复 HK 股票代码识别(支持 hk1810 等 1-5 位格式) - AkShare API 调用添加重试机制和失败缓存 (#62) - 钉钉 Webhook 支持 20KB 限制分块发送 (#61) Closes #61, #62 --- .gitignore | 6 +- CHANGELOG.md | 29 +++ README.md | 107 +--------- data_provider/akshare_fetcher.py | 99 ++++++--- docs/full-guide.md | 345 +++++++++++++++++++++++++++++++ market_analyzer.py | 20 +- notification.py | 153 ++++++++++++-- 7 files changed, 605 insertions(+), 154 deletions(-) create mode 100644 docs/full-guide.md diff --git a/.gitignore b/.gitignore index abcfb71bf..6b16be23b 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,8 @@ Thumbs.db .coverage htmlcov/ -docs/ +local/ -run.sh \ No newline at end of file +run.sh + +verify_*.py \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0a918ea..8138ae417 100644 --- a/CHANGELOG.md +++ b/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 ### 新增 diff --git a/README.md b/README.md index ba4bf0849..6398b0a7b 100644 --- a/README.md +++ b/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 - [ ] 历史分析回测 -- [ ] 多策略支持 -- [ ] 港股/美股支持 +- [ ] 美股支持 ## 🤝 贡献 diff --git a/data_provider/akshare_fetcher.py b/data_provider/akshare_fetcher.py index b0f2b77fa..93890da15 100644 --- a/data_provider/akshare_fetcher.py +++ b/data_provider/akshare_fetcher.py @@ -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] diff --git a/docs/full-guide.md b/docs/full-guide.md new file mode 100644 index 000000000..8422d774b --- /dev/null +++ b/docs/full-guide.md @@ -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) diff --git a/market_analyzer.py b/market_analyzer.py index e6c123d94..1c16d34b8 100644 --- a/market_analyzer.py +++ b/market_analyzer.py @@ -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 = '涨跌幅' diff --git a/notification.py b/notification.py index 29f1bfb9e..04850a7b9 100644 --- a/notification.py +++ b/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: """