mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
Merge pull request #10 from liuxu379/feature/feishu_word:support feishu doc
添加飞书文档支持
This commit is contained in:
5
.github/workflows/daily_analysis.yml
vendored
5
.github/workflows/daily_analysis.yml
vendored
@@ -75,6 +75,11 @@ jobs:
|
||||
EMAIL_RECEIVERS: ${{ secrets.EMAIL_RECEIVERS }}
|
||||
# 方式五:自定义 Webhook(支持钉钉、Discord、Slack、Bark等,多个用逗号分隔)
|
||||
CUSTOM_WEBHOOK_URLS: ${{ secrets.CUSTOM_WEBHOOK_URLS }}
|
||||
# 方式六:飞书文档
|
||||
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
||||
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
||||
FEISHU_FOLDER_TOKEN: ${{ secrets.FEISHU_FOLDER_TOKEN }}
|
||||
|
||||
|
||||
# 自选股列表 (从 secrets 或使用默认值)
|
||||
STOCK_LIST: ${{ secrets.STOCK_LIST || '600519' }}
|
||||
|
||||
14
README.md
14
README.md
@@ -70,11 +70,23 @@
|
||||
| `EMAIL_SENDER` | 发件人邮箱(如 `xxx@qq.com`) | 可选 |
|
||||
| `EMAIL_PASSWORD` | 邮箱授权码(非登录密码) | 可选 |
|
||||
| `EMAIL_RECEIVERS` | 收件人邮箱(多个用逗号分隔,留空则发给自己) | 可选 |
|
||||
| `CUSTOM_WEBHOOK_URLS` | 自定义 Webhook(多个用逗号分隔) | 可选 |
|
||||
| `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 后面参数) | 可选 |
|
||||
|
||||
> *注:至少配置一个渠道,配置多个则同时推送到所有渠道
|
||||
>
|
||||
> 自定义 Webhook 支持:钉钉、Discord、Slack、Bark、自建服务等任意支持 POST JSON 的 Webhook
|
||||
>
|
||||
|
||||
> 通过飞书应用创建的飞书文档,里面的内容不会出现已截断的情况。应用创建好后需要执行以下操作:
|
||||
>
|
||||
> 1.Github 配置对应 Secret
|
||||
>
|
||||
> 2.创建群组,在群组设置->群机器人,将创建的应用添加到群组内,算上飞书 Webhook,此时群组应该会有两个机器人
|
||||
>
|
||||
> 3.点击飞书云盘文件夹的“...”,将群组添加为协作者,权限设置为可管理
|
||||
|
||||
**其他配置**
|
||||
|
||||
|
||||
10
config.py
10
config.py
@@ -30,7 +30,12 @@ class Config:
|
||||
|
||||
# === 自选股配置 ===
|
||||
stock_list: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# === 飞书云文档配置 ===
|
||||
feishu_app_id: Optional[str] = None
|
||||
feishu_app_secret: Optional[str] = None
|
||||
feishu_folder_token: Optional[str] = None # 目标文件夹 Token
|
||||
|
||||
# === 数据源 API Token ===
|
||||
tushare_token: Optional[str] = None
|
||||
|
||||
@@ -159,6 +164,9 @@ class Config:
|
||||
|
||||
return cls(
|
||||
stock_list=stock_list,
|
||||
feishu_app_id=os.getenv('FEISHU_APP_ID'),
|
||||
feishu_app_secret=os.getenv('FEISHU_APP_SECRET'),
|
||||
feishu_folder_token=os.getenv('FEISHU_FOLDER_TOKEN'),
|
||||
tushare_token=os.getenv('TUSHARE_TOKEN'),
|
||||
gemini_api_key=os.getenv('GEMINI_API_KEY'),
|
||||
gemini_model=os.getenv('GEMINI_MODEL', 'gemini-3-flash-preview'),
|
||||
|
||||
165
feishu_doc.py
Normal file
165
feishu_doc.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# feishu_doc.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import json
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.docx.v1 import *
|
||||
from typing import List, Dict, Any, Optional
|
||||
from config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeishuDocManager:
|
||||
"""飞书云文档管理器 (基于官方 SDK lark-oapi)"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
self.app_id = self.config.feishu_app_id
|
||||
self.app_secret = self.config.feishu_app_secret
|
||||
self.folder_token = self.config.feishu_folder_token
|
||||
|
||||
# 初始化 SDK 客户端
|
||||
# SDK 会自动处理 tenant_access_token 的获取和刷新,无需人工干预
|
||||
if self.is_configured():
|
||||
self.client = lark.Client.builder() \
|
||||
.app_id(self.app_id) \
|
||||
.app_secret(self.app_secret) \
|
||||
.log_level(lark.LogLevel.INFO) \
|
||||
.build()
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""检查配置是否完整"""
|
||||
return bool(self.app_id and self.app_secret and self.folder_token)
|
||||
|
||||
def create_daily_doc(self, title: str, content_md: str) -> Optional[str]:
|
||||
"""
|
||||
创建日报文档
|
||||
"""
|
||||
if not self.client or not self.is_configured():
|
||||
logger.warning("飞书 SDK 未初始化或配置缺失,跳过创建")
|
||||
return None
|
||||
|
||||
try:
|
||||
# 1. 创建文档
|
||||
# 使用官方 SDK 的 Builder 模式构造请求
|
||||
create_request = CreateDocumentRequest.builder() \
|
||||
.request_body(CreateDocumentRequestBody.builder()
|
||||
.folder_token(self.folder_token)
|
||||
.title(title)
|
||||
.build()) \
|
||||
.build()
|
||||
|
||||
response = self.client.docx.v1.document.create(create_request)
|
||||
|
||||
if not response.success():
|
||||
logger.error(f"创建文档失败: {response.code} - {response.msg} - {response.error}")
|
||||
return None
|
||||
|
||||
doc_id = response.data.document.document_id
|
||||
# 这里的 domain 只是为了生成链接,实际访问会重定向
|
||||
doc_url = f"https://feishu.cn/docx/{doc_id}"
|
||||
logger.info(f"飞书文档创建成功: {title} (ID: {doc_id})")
|
||||
|
||||
# 2. 解析 Markdown 并写入内容
|
||||
# 将 Markdown 转换为 SDK 需要的 Block 对象列表
|
||||
blocks = self._markdown_to_sdk_blocks(content_md)
|
||||
|
||||
# 飞书 API 限制每次写入 Block 数量(建议 50 个左右),分批写入
|
||||
batch_size = 50
|
||||
doc_block_id = doc_id # 文档本身也是一个 block
|
||||
|
||||
for i in range(0, len(blocks), batch_size):
|
||||
batch_blocks = blocks[i:i + batch_size]
|
||||
|
||||
# 构造批量添加块的请求
|
||||
batch_add_request = CreateDocumentBlockChildrenRequest.builder() \
|
||||
.document_id(doc_id) \
|
||||
.block_id(doc_block_id) \
|
||||
.request_body(CreateDocumentBlockChildrenRequestBody.builder()
|
||||
.children(batch_blocks) # SDK 需要 Block 对象列表
|
||||
.index(-1) # 追加到末尾
|
||||
.build()) \
|
||||
.build()
|
||||
|
||||
write_resp = self.client.docx.v1.document_block_children.create(batch_add_request)
|
||||
|
||||
if not write_resp.success():
|
||||
logger.error(f"写入文档内容失败(批次{i}): {write_resp.code} - {write_resp.msg}")
|
||||
|
||||
logger.info(f"文档内容写入完成")
|
||||
return doc_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"飞书文档操作异常: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return None
|
||||
|
||||
def _markdown_to_sdk_blocks(self, md_text: str) -> List[Block]:
|
||||
"""
|
||||
将简单的 Markdown 转换为飞书 SDK 的 Block 对象
|
||||
"""
|
||||
blocks = []
|
||||
lines = md_text.split('\n')
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# 默认普通文本 (Text = 2)
|
||||
block_type = 2
|
||||
text_content = line
|
||||
|
||||
# 识别标题
|
||||
if line.startswith('# '):
|
||||
block_type = 3 # H1
|
||||
text_content = line[2:]
|
||||
elif line.startswith('## '):
|
||||
block_type = 4 # H2
|
||||
text_content = line[3:]
|
||||
elif line.startswith('### '):
|
||||
block_type = 5 # H3
|
||||
text_content = line[4:]
|
||||
elif line.startswith('---'):
|
||||
# 分割线
|
||||
blocks.append(Block.builder()
|
||||
.block_type(22)
|
||||
.divider(Divider.builder().build())
|
||||
.build())
|
||||
continue
|
||||
|
||||
# 构造 Text 类型的 Block
|
||||
# SDK 的结构嵌套比较深: Block -> Text -> elements -> TextElement -> TextRun -> content
|
||||
text_run = TextRun.builder() \
|
||||
.content(text_content) \
|
||||
.text_element_style(TextElementStyle.builder().build()) \
|
||||
.build()
|
||||
|
||||
text_element = TextElement.builder() \
|
||||
.text_run(text_run) \
|
||||
.build()
|
||||
|
||||
text_obj = Text.builder() \
|
||||
.elements([text_element]) \
|
||||
.style(TextStyle.builder().build()) \
|
||||
.build()
|
||||
|
||||
# 根据 block_type 放入正确的属性容器
|
||||
block_builder = Block.builder().block_type(block_type)
|
||||
|
||||
if block_type == 2:
|
||||
block_builder.text(text_obj)
|
||||
elif block_type == 3:
|
||||
block_builder.heading1(text_obj)
|
||||
elif block_type == 4:
|
||||
block_builder.heading2(text_obj)
|
||||
elif block_type == 5:
|
||||
block_builder.heading3(text_obj)
|
||||
|
||||
blocks.append(block_builder.build())
|
||||
|
||||
return blocks
|
||||
43
main.py
43
main.py
@@ -35,10 +35,11 @@ import logging
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, date
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from feishu_doc import FeishuDocManager
|
||||
|
||||
from config import get_config, Config
|
||||
from storage import get_db, DatabaseManager
|
||||
@@ -730,12 +731,17 @@ def run_full_analysis(
|
||||
)
|
||||
|
||||
# 2. 运行大盘复盘(如果启用且不是仅个股模式)
|
||||
market_report = ""
|
||||
if config.market_review_enabled and not args.no_market_review:
|
||||
run_market_review(
|
||||
# 只调用一次,并获取结果
|
||||
review_result = run_market_review(
|
||||
notifier=pipeline.notifier,
|
||||
analyzer=pipeline.analyzer,
|
||||
search_service=pipeline.search_service
|
||||
)
|
||||
# 如果有结果,赋值给 market_report 用于后续飞书文档生成
|
||||
if review_result:
|
||||
market_report = review_result
|
||||
|
||||
# 输出摘要
|
||||
if results:
|
||||
@@ -748,6 +754,39 @@ def run_full_analysis(
|
||||
)
|
||||
|
||||
logger.info("\n任务执行完成")
|
||||
|
||||
# === 新增:生成飞书云文档 ===
|
||||
try:
|
||||
feishu_doc = FeishuDocManager()
|
||||
if feishu_doc.is_configured() and (results or market_report):
|
||||
logger.info("正在创建飞书云文档...")
|
||||
|
||||
# 1. 准备标题 "01-01 13:01大盘复盘"
|
||||
tz_cn = timezone(timedelta(hours=8))
|
||||
now = datetime.now(tz_cn)
|
||||
doc_title = f"{now.strftime('%Y-%m-%d %H:%M')} 大盘复盘"
|
||||
|
||||
# 2. 准备内容 (拼接个股分析和大盘复盘)
|
||||
full_content = ""
|
||||
|
||||
# 添加大盘复盘内容(如果有)
|
||||
if market_report:
|
||||
full_content += f"# 📈 大盘复盘\n\n{market_report}\n\n---\n\n"
|
||||
|
||||
# 添加个股决策仪表盘(使用 NotificationService 生成)
|
||||
if results:
|
||||
dashboard_content = pipeline.notifier.generate_dashboard_report(results)
|
||||
full_content += f"# 🚀 个股决策仪表盘\n\n{dashboard_content}"
|
||||
|
||||
# 3. 创建文档
|
||||
doc_url = feishu_doc.create_daily_doc(doc_title, full_content)
|
||||
if doc_url:
|
||||
logger.info(f"飞书云文档创建成功: {doc_url}")
|
||||
# 可选:将文档链接也推送到群里
|
||||
pipeline.notifier.send(f"[{now.strftime('%Y-%m-%d %H:%M')}] 复盘文档创建成功: {doc_url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"飞书文档生成失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"分析流程执行失败: {e}")
|
||||
|
||||
@@ -14,6 +14,9 @@ tushare>=1.4.0 # Backup 1: 挖地兔 Pro API
|
||||
baostock>=0.8.0 # Backup 2: 证券宝数据
|
||||
yfinance>=0.2.0 # Fallback: Yahoo Finance
|
||||
|
||||
#飞书
|
||||
lark-oapi>=1.0.0 # 飞书API
|
||||
|
||||
# 数据处理
|
||||
pandas>=2.0.0 # 数据分析
|
||||
numpy>=1.24.0 # 数值计算
|
||||
|
||||
Reference in New Issue
Block a user