python读取飞书云文档内容
飞书新版云文档(docx)的正文若要以机器可读形式落到本地或管线里,可直接走开放平台提供的 获取云文档内容 接口,由服务端把结构化的 docx 转成 Markdown 字符串返回。仓库里的 .cursor/skills/feishu-reader 把这一链路封装成可脚本化、也可被其他 Python 模块 import 的小工具,并在 Cursor 里用 SKILL 描述「何时用、怎么跑、权限踩坑」。
原理概览
整条链路分两步,对应飞书开放平台常见的 拿令牌 → 带令牌调业务接口 模式。
-
换取
tenant_access_token
向POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal提交应用的app_id与app_secret,响应体里带有短时有效的租户访问令牌。本实现用该令牌代表「自建应用」访问本租户内已授权的资源。 -
拉取文档 Markdown
向GET https://open.feishu.cn/open-apis/docs/v1/content发起请求,查询字符串固定两类关键参数:doc_type=docx、content_type=markdown,并传入目标文档的doc_token。请求头里携带Authorization: Bearer <tenant_access_token>。成功时响应 JSON 的data.content即为 Markdown 正文字符串。
官方文档入口:获取云文档内容。
前置条件(与接口契约一致)
- 应用类型为自建应用,且已开通
docs:document.content:read(控制台里常表述为「查看云文档内容」一类)。 - 目标文档为 新版 docx;旧版 doc 等不同载体需换其他 API,本脚本不按
docx去调会得不到有效结果。 - 文档侧需对应用可见:例如在文档里「添加文档应用」等;否则容易收到
2889902一类无权限错误。
doc_token 从哪里来
doc_token 是文档在开放平台语境下的标识(常见长度约 22~27 个字符)。可从用户直接提供的 token、或从文档 URL 中符合 docx 规则的路径段解析,具体规则以官方 docx 概述 为准。
代码结构:python/main.py
实现集中在单文件里,依赖仅 requests(见下节 requirements.txt)。
依赖:requirements.txt
requests>=2.28.0,<3
main.py 全文(脱敏说明)
下文与仓库中 python/main.py 逻辑一致;脱敏处理如下,避免在公开文中出现真实应用凭证:
get_tenant_access_token里os.environ.get("FEISHU_APP_ID", …)、os.environ.get("FEISHU_APP_SECRET", …)的第二个参数(若本地代码曾写有字面量默认值)在文中统一改为""。实际运行时请只通过环境变量或函数参数传入 ID/Secret,勿依赖文末占位。- 文中不出现任何
doc_token、tenant_access_token的样例真值。
"""
飞书开放平台:获取云文档 Markdown 内容(GET /open-apis/docs/v1/content)。
仅限新版文档 docx;需应用开通 docs:document.content:read,且文档侧已对应用授权。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
import requests
BASE_URL = "https://open.feishu.cn"
AUTH_PATH = "/open-apis/auth/v3/tenant_access_token/internal"
CONTENT_PATH = "/open-apis/docs/v1/content"
class FeishuApiError(RuntimeError):
def __init__(self, message: str, *, code: int | None = None, raw: Any = None):
super().__init__(message)
self.code = code
self.raw = raw
def get_tenant_access_token(
app_id: str | None = None,
app_secret: str | None = None,
*,
session: requests.Session | None = None,
base_url: str = BASE_URL,
) -> str:
app_id = app_id or os.environ.get("FEISHU_APP_ID", "").strip()
app_secret = app_secret or os.environ.get("FEISHU_APP_SECRET", "").strip()
if not app_id or not app_secret:
raise FeishuApiError(
"缺少 FEISHU_APP_ID / FEISHU_APP_SECRET(环境变量或参数)"
)
sess = session or requests.Session()
url = f"{base_url.rstrip('/')}{AUTH_PATH}"
resp = sess.post(
url,
json={"app_id": app_id, "app_secret": app_secret},
headers={"Content-Type": "application/json; charset=utf-8"},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise FeishuApiError(
f"获取 tenant_access_token 失败: code={data.get('code')} msg={data.get('msg')}",
code=data.get("code"),
raw=data,
)
token = data.get("tenant_access_token")
if not token:
raise FeishuApiError("响应中无 tenant_access_token", raw=data)
return str(token)
def get_doc_markdown(
doc_token: str,
*,
access_token: str | None = None,
lang: str | None = None,
session: requests.Session | None = None,
base_url: str = BASE_URL,
) -> str:
token = access_token or get_tenant_access_token(session=session, base_url=base_url)
sess = session or requests.Session()
url = f"{base_url.rstrip('/')}{CONTENT_PATH}"
params: dict[str, str] = {
"doc_token": doc_token.strip(),
"doc_type": "docx",
"content_type": "markdown",
}
if lang:
params["lang"] = lang
resp = sess.get(
url,
params=params,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
},
timeout=60,
)
resp.raise_for_status()
data = resp.json()
if data.get("code") != 0:
raise FeishuApiError(
f"获取文档内容失败: code={data.get('code')} msg={data.get('msg')}",
code=data.get("code"),
raw=data,
)
inner = data.get("data") or {}
content = inner.get("content")
if content is None:
raise FeishuApiError("响应中无 data.content", raw=data)
return str(content)
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description=(
"读取飞书新版文档(docx)的 Markdown 正文。"
" 仅支持 GET docs/v1/content;旧版 doc 请换其他 API。"
)
)
p.add_argument(
"doc_token",
nargs="?",
help="云文档 doc_token(22–27 字符);可与 --doc-token 二选一",
)
p.add_argument(
"--doc-token",
dest="doc_token_opt",
default=None,
metavar="TOKEN",
help="同位置参数 doc_token",
)
p.add_argument(
"--lang",
choices=("zh", "en", "ja"),
default=None,
help="@用户 展示名称语言,默认由服务端决定(多为 zh)",
)
p.add_argument(
"--out",
"-o",
metavar="PATH",
help="写入 Markdown 文件;默认输出到 stdout",
)
p.add_argument(
"--raw-json",
action="store_true",
help="调试:将完整 JSON 响应打印到 stderr",
)
return p
def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
doc_token = (args.doc_token_opt or args.doc_token or "").strip()
if not doc_token:
sys.stderr.write("error: 请提供 doc_token(位置参数或 --doc-token)\n")
return 2
session = requests.Session()
try:
token = get_tenant_access_token(session=session)
url = f"{BASE_URL}{CONTENT_PATH}"
params: dict[str, str] = {
"doc_token": doc_token,
"doc_type": "docx",
"content_type": "markdown",
}
if args.lang:
params["lang"] = args.lang
resp = session.get(
url,
params=params,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
},
timeout=60,
)
resp.raise_for_status()
payload = resp.json()
if args.raw_json:
sys.stderr.write(json.dumps(payload, ensure_ascii=False, indent=2))
sys.stderr.write("\n")
if payload.get("code") != 0:
raise FeishuApiError(
f"获取文档内容失败: code={payload.get('code')} msg={payload.get('msg')}",
code=payload.get("code"),
raw=payload,
)
inner = payload.get("data") or {}
content = inner.get("content")
if content is None:
raise FeishuApiError("响应中无 data.content", raw=payload)
markdown = str(content)
except FeishuApiError as e:
sys.stderr.write(f"{e}\n")
if e.raw is not None:
sys.stderr.write(json.dumps(e.raw, ensure_ascii=False, indent=2) + "\n")
return 1
except requests.RequestException as e:
sys.stderr.write(f"HTTP 请求失败: {e}\n")
return 1
if args.out:
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
f.write(markdown)
else:
sys.stdout.write(markdown)
if not markdown.endswith("\n"):
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
模块职责速览(对照上文)
- 常量与
FeishuApiError:BASE_URL/AUTH_PATH/CONTENT_PATH拆分便于替换;异常携带code、raw便于 CLI 向 stderr 打印完整服务端 JSON。 get_tenant_access_token:POST换tenant_access_token,校验业务code != 0。get_doc_markdown:GET拉data.content;可与get_tenant_access_token共用Session(通过参数传入)。main(CLI):--raw-json把完整响应 JSON 打到 stderr;成功时 stdout 输出 Markdown 或-o写文件。退出码:成功0;FeishuApiError/RequestException为1;未提供doc_token为2。
凭证:生产或对外分发应只通过环境变量或密钥管理注入;仓库若曾提交过字面量默认值,应删除、改 "" 或改为必填环境变量,并轮换已暴露的 Secret。
.cursor/skills/feishu-reader/SKILL.md 做什么
SKILL 不负责执行代码,它规定 在编辑器/代理上下文中何时拉取飞书正文、如何从仓库根运行哪条命令、stdout/stderr 语义、以及权限/依赖故障时的处理顺序。这样做的效果是把「接口文档 + 运维约定」固化在仓库里,与 main.py 同源维护,避免口头步骤漂移。
要点包括:
- 安装:
python -m pip install -r .cursor/skills/feishu-reader/python/requirements.txt - Invoking:
python ".cursor/skills/feishu-reader/python/main.py" "<doc_token>"或带--lang zh、-o等。
依赖上层工作流的 SKILL(例如把长文导出到 out/ 或 blog/)可在拉取 Markdown 之后再衔接,本文不展开。
小结
| 环节 | 实现要点 |
|---|---|
| 认证 | POST .../tenant_access_token/internal,Env:FEISHU_APP_ID / FEISHU_APP_SECRET |
| 内容 | GET .../docs/v1/content,docx + markdown,Bearer 令牌 |
| 可复用 API | get_tenant_access_token、get_doc_markdown |
| 命令行 | stdout 正文;--raw-json 走 stderr;-o 写文件 |
整体上,这是一个 薄封装:信任飞书服务端完成 docx→Markdown 的转换,本地只处理 HTTP、JSON 与最基本的错误分流。扩展方向通常包括:复用 Session 批量拉多篇文档、对超大正文做流式或分页(若官方后续提供)、或将 token 获取改为缓存以减少频率限制压力——均以开放平台最新文档为准。