Merge pull request #10 from liuxu379/feature/feishu_word:support feishu doc

添加飞书文档支持
This commit is contained in:
mumu
2026-01-13 22:52:51 +08:00
committed by GitHub
6 changed files with 236 additions and 4 deletions

View File

@@ -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' }}

View File

@@ -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.点击飞书云盘文件夹的“...”,将群组添加为协作者,权限设置为可管理
**其他配置**

View File

@@ -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
View 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
View File

@@ -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}")

View File

@@ -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 # 数值计算