python读取飞书云文档内容

飞书新版云文档(docx)的正文若要以机器可读形式落到本地或管线里,可直接走开放平台提供的 获取云文档内容 接口,由服务端把结构化的 docx 转成 Markdown 字符串返回。仓库里的 .cursor/skills/feishu-reader 把这一链路封装成可脚本化、也可被其他 Python 模块 import 的小工具,并在 Cursor 里用 SKILL 描述「何时用、怎么跑、权限踩坑」。

原理概览

整条链路分两步,对应飞书开放平台常见的 拿令牌 → 带令牌调业务接口 模式。

  1. 换取 tenant_access_token
    POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal 提交应用的 app_idapp_secret,响应体里带有短时有效的租户访问令牌。本实现用该令牌代表「自建应用」访问本租户内已授权的资源。

  2. 拉取文档 Markdown
    GET https://open.feishu.cn/open-apis/docs/v1/content 发起请求,查询字符串固定两类关键参数:doc_type=docxcontent_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_tokenos.environ.get("FEISHU_APP_ID", …)os.environ.get("FEISHU_APP_SECRET", …)第二个参数(若本地代码曾写有字面量默认值)在文中统一改为 ""。实际运行时请只通过环境变量或函数参数传入 ID/Secret,勿依赖文末占位。
  • 文中不出现任何 doc_tokentenant_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())

模块职责速览(对照上文)

  • 常量与 FeishuApiErrorBASE_URL / AUTH_PATH / CONTENT_PATH 拆分便于替换;异常携带 coderaw 便于 CLI 向 stderr 打印完整服务端 JSON。
  • get_tenant_access_tokenPOSTtenant_access_token,校验业务 code != 0
  • get_doc_markdownGETdata.content;可与 get_tenant_access_token 共用 Session(通过参数传入)。
  • main(CLI)--raw-json 把完整响应 JSON 打到 stderr;成功时 stdout 输出 Markdown 或 -o 写文件。退出码:成功 0FeishuApiError / RequestException1;未提供 doc_token2

凭证:生产或对外分发应只通过环境变量或密钥管理注入;仓库若曾提交过字面量默认值,应删除、改 "" 或改为必填环境变量,并轮换已暴露的 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/contentdocx + markdown,Bearer 令牌
可复用 API get_tenant_access_tokenget_doc_markdown
命令行 stdout 正文;--raw-json 走 stderr;-o 写文件

整体上,这是一个 薄封装:信任飞书服务端完成 docx→Markdown 的转换,本地只处理 HTTP、JSON 与最基本的错误分流。扩展方向通常包括:复用 Session 批量拉多篇文档、对超大正文做流式或分页(若官方后续提供)、或将 token 获取改为缓存以减少频率限制压力——均以开放平台最新文档为准。