恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
用Codex插件打通飞书API,实现Markdown文档自动转换与图片上传
首页
资讯中心
/
用Codex插件打通飞书API,实现Markdown文档自动转换与图片上传
用Codex插件打通飞书API,实现Markdown文档自动转换与图片上传
发布时间:2026/10/7 6:14:23
先说结论我花了一天加半个通宵把Codex 插件和飞书开放平台打通了。现在本地任何一篇 Markdown 文档只要在终端敲一条命令就能变成一篇飞书云文档里面引用的本地图片也会自动压缩、转格式、上传到飞书图床再按正确位置插入文档。整个过程对一篇带二十多张图的万字笔记来说大概 8 秒。这篇文章就是我从零开始爆肝的完整记录包括踩过的坑、错误的接口调用、把我逼疯的错误码以及最后稳定运行的实现思路。如果你也在折腾 Markdown 和飞书之间的自动化这篇应该能帮你省掉半天起步时间。先说明一下我的使用背景。我平时写各种技术笔记、项目复盘和方案文档都习惯用 Markdown 存本地但团队协作和知识沉淀都在飞书上。以前我发布一篇文档的流程是打开飞书 → 新建文档 → 把 Markdown 源码粘进去 → 发现图片全部裂掉 → 再一张张手动拖图 → 调整标题层级和代码块样式 → 修复表格对不齐的问题。一篇万字文档折腾下来至少要四十分钟其中图片处理占了大头。所以这次我下定决心把这件事完全自动化而且要把本地 Markdown 里的图片自动转成飞书能用的形态。1. 为什么不直接复制粘贴非写个插件不可1.1 飞书富文本和 Markdown 的天然隔阂飞书文档本身是块结构Block它的内容是 JSON 形式的块数组不是一串纯文本。你往飞书文档编辑器里粘贴内容飞书会用自己的解析器把它转成块但这个过程对 Markdown 的支持非常有限标题层级勉强能识别列表大部分能识别代码块经常折叠成普通文本最致命的是这种语法根本不会触发图片上传你粘进去的就只是一段带链接的文本。飞书也提供了从 Markdown 导入文档的功能但必须使用导出后的.md文件包而且它要求图片路径是能访问的网络地址本地图片它不认。这个限制直接堵死了我本地写、飞书发布的工作流。1.2 现成方案的硬伤在动手写之前我其实先搜了一遍现成工具。市面上确实有开源的md2feishu类项目也有一些商业插件但试用下来都不满意要么只支持 HTML 转飞书不支持 Markdown 解析要么图片处理逻辑很草率只处理网络图不处理本地相对路径要么表格转完只剩纯文本排版完全崩掉要么依赖特定笔记软件无法作为命令行工具被嵌入到自动化流程里。更关键的是这些工具的解析规则不会跟着我实际的 Markdown 写法走。我的文档里充满各种自定义 callout、嵌套列表、LaTeX 公式块、代码块注释这些都需要能按我的需求定制。与其去改别人的代码不如自己写一个而且我手上正好有一个能帮我快速编码的 Codex 环境。1.3 我把宝押在 Codex 插件上这里说的 Codex 插件指的是基于Codex CLI的自动化能力我先用 Codex 辅助开发一个本地命令行工具再把工具封装成 Codex 可调用的 skill/插件。后续只要在 Codex 的对话窗口里说把这篇文章发到飞书Codex 就会自动解析路径、调用脚本、确认结果等于给代码库增加了一项发布到飞书的能力。选这个方案的原因很简单其一飞书开放平台的接口文档非常庞大靠手读很费劲但让 Codex 根据报错信息去查文档、改代码效率高很多其二代码更新后我只需要维护一个脚本不需要依赖任何 GUI 程序其三后续我可以把网页另存为 Markdown也做成对向 skill让整个知识收集流程闭环。2. 动手之前先把插件的脾气摸清楚2.1 输入输出边界本地 Markdown 到飞书文档块这一步很重要先定义输入输出不然后面会越写越乱。输入是一个本地.md文件允许带 YAML frontmatter标题、标签等元信息正文使用我日常的 Markdown 子集ATX 标题、段落、行内加粗和斜体、行内代码、|表格、无序/有序列表、引用、围栏代码块、图片语法、[text](url)链接。输出是一篇飞书云文档docx文档用飞书的 Block JSON 描述。我特意没有用飞书多维表格做表格目标因为多维表格是另一个物种字段类型、视图逻辑和 Markdown 表格根本不是一回事。2.2 模块拆解与技术选型我的实现拆成四层每一层都是独立的模块职责实现方式解析层把 Markdown 字符串拆成语义块Python 正则 分块解析器图片处理层检测本地图片、转格式/压缩、上传Pillow requests飞书 API 层获取 token、建文档、写块、传图requests配置层管理 App ID/Secret、默认父文件夹YAML 环境变量技术选型上我直接用 Python 3.11没有引入 playwright 这种重依赖。requests 配 Pillow 足够覆盖图片处理和接口调用的全部需求。为什么不用 Node.js因为我本地 Python 生态最全Pillow、PyYAML、requests 都是现成轮子写起来最少折腾。2.3 飞书自建应用权限清单飞书文档的一切操作都用自建应用的身份走开放平台接口所以第一步是去飞书开放平台后台创建一个企业自建应用然后开通权限。我踩的第一个大坑就在这里。这里有一个我在文档里反复看到的清单直接对照开通即可docx:document创建、读取、编辑文档内容drive:drive获取父文件夹信息、创建云文档drive:file上传图片到云空间实际上是 media 资源im:resource如果你的图片想通过消息资源接口传但这里我用的云空间资源contact:user.base:readonly某些接口会校验操作者身份建议一起开application:application可选如果要在机器人侧看到应用状态。除了权限还要把自建应用的发布版本审阅通过。没通过之前token 虽然能拿到但 docx 接口经常返回 10003权限错误。这是最常见的非代码级问题。2.4 插件形态命令行入口 Codex skill 注册工具本身我设计成 CLImd2feishu send path。成功以后我会把调用封装成 Codex skill在 AGENTS.md 或 skills 目录里注册一条发到飞书规则。Codex 在执行时会先识别我给出的路径再运行脚本然后把返回的文档链接汇报给我。代码结构上一条完整链路大概是md2feishu send ./docs/团队周报.md # - 解析 frontmatter拿到 title: 团队周报 # - 读取 Markdown解析成 blocks 候选 # - 依次上传图片替换路径为飞书 image_key # - 调用 docx 接口创建文档并写入 blocks # - 输出文档 URL3. 核心链路实现Markdown 解析、图片自动转换与文档创建3.1 把 Markdown 切成可以映射的块Markdown 解析看起来简单但实际上比想象中麻烦因为飞书块类型和 Markdown 元素不是一一对应。我的解析策略是先把文档按行拆开用状态机识别是否处于代码块、是否处于表格、是否处于列表。然后合并相邻的普通文本行成一个段落。下面是我用来做块映射的核心函数节选细节def line_to_block(line: str) - dict | None: line line.rstrip() if line.startswith(): return handle_code_block() if line.startswith(|): return handle_table() if re.match(r^#{1,6}\s, line): level len(line) - len(line.lstrip(#)) text line.strip(#).strip() return feishu_block(heading, level, text) if re.match(r^[-*]\s, line): return feishu_block(bullet, line.lstrip(-* ).strip()) if re.match(r^\s, line): return feishu_block(quote, line.lstrip( ).strip()) if line.strip() ---: return None # 分隔线或 frontmatter 结尾 return feishu_block(text, line.strip())这里我只写了判断逻辑实际实现里每个handle_*内部还维护了一个临时缓冲区。比如代码块碰到第一对 后我要持续吞行到结束标记并把内部内容原样保留成一条文本再包进飞书的 code block 结构里。飞书一个文档可以有多个 block但父块和子块之间靠children字段嵌套。也就是说列表项、引用块、代码块如果内部有换行需要把子块挂到父块的 children 数组里。这一步不处理飞书展示时就会平铺开来缩进完全消失。3.2 图片自动转换的三层含义标题里说的图片自动转实际操作中包含了三层含义缺一不可。第一层路径转换。我在 Markdown 里写的是相对路径比如解析阶段要拿到当前 md 文件所在的目录拼成绝对路径再判断文件是否存在。这里要特别处理中文文件名和带空格的文件名因为飞书上传接口按 URL 编码来识别直接传原生 unicode 容易出意外。raw_path re.search(r!\[.*?\]\((.*?)\), md_text).group(1) abs_path (md_dir / raw_path).resolve() mime_type mimetypes.guess_type(abs_path)[0]第二层格式与尺寸转换。飞书图片接口对体积和格式有要求。PNG 超过一定体积、GIF 动图在某些 block 场景下不支持所以我在上传前统一做了一道转换from PIL import Image img Image.open(abs_path) max_size 1800 # 长边限制防止超长图被飞书压缩变形 if max(img.size) max_size: img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) if img.mode in (RGBA, P): img img.convert(RGB) # 或者保留 alpha 转成 webp按飞书实际支持情况取舍 out_path tempfile.mktemp(suffix.jpg) img.save(out_path, JPEG, quality88)这里为什么默认转成 JPEG实测下来飞书文档对 JPEG 的兼容性最好渲染稳定文件体积也小。如果你一定要保留透明背景建议转 PNG 并控制文件大小在 10MB 以内超过就容易触发上传接口的 1005 报错。第三层引用转换。上传成功后飞书返回的是一个file_token需要在文档块中用image块引用{ block_type: 27, image: { token: file_token_xxx, width: 800, height: 400 } }block_type 27 是飞书文档里的图片块。这里有个细节如果你插入的图片不指定宽高飞书会按原图尺寸渲染但如果原图很大飞书可能会限制显示宽度。我在测试中统一设置了宽高值和 Markdown 语法里的 width 参数对齐。3.3 飞书文档块组装与批量写入解析完所有块之后下一步是创建文档并写入内容。创建文档resp requests.post( f{base}/docx/v1/documents, headers{Authorization: fBearer {token}}, json{ title: frontmatter.get(title, 未命名文档), folder_token: config[folder_token], }, ) document_id resp.json()[data][document][document_id]然后写入内容块。最初我尝试用docx/v1/documents/{id}/blocks/{parent}逐个创建块但一个 200 行的文档要调一两百次接口慢且容易触发限流。后面改成先构造一个完整的children数组再通过批量接口一次性写入payload {children: blocks} # children 里的每个元素是一个 block 对象 resp requests.post( f{base}/docx/v1/documents/{document_id}/blocks/{document_id}/children, headers{Authorization: fBearer {token}}, jsonpayload, )这一改效率从每分钟几十块直接提到一次写几千块。代价是构造数组时对内存有一点压力但日常文档都在几千块以内完全可接受。3.4 Markdown 表格怎么处理表格是这次开发的难点之一。飞书文档组件本身有表格块block_type 31但它要求你按固定的行/列数组格式传数据内部结构比较绕。我先说结论我把 Markdown 表格统一转成飞书表格块而不是变成纯文本或转成多维表格。多维表格的字段系统文本、数字、单选、人员和 Markdown 表格的二维数据模型并不对应强转会导致数据语义丢失而且多维表格的 API 类型完全不同太重了。转换的核心是def md_table_to_feishu_table(rows: list[list[str]]) - dict: # rows[0] 是表头 table_block { block_type: 31, table: { property: { row_size: len(rows), column_size: len(rows[0]), }, cells: [], }, } for r_idx, row in enumerate(rows): for c_idx, cell in enumerate(row): cell_block cell_to_text_block(cell) table_block[table][cells].append({ row_index: r_idx, column_index: c_idx, cell: {children: [cell_block]}, }) return table_block需要注意飞书表格块要求每个单元格必须是一个合法的 block 数组不能直接塞字符串。我在构造时先对单元格内容做了一次行内解析把**bold**转成 text_run 的 bold 字段否则单元格里就是纯文本。还有一个小坑表格内单元格的row_index和column_index必须连续且从 0 开始如果中间跳过了某个位置又不补空块飞书接口会返回结构错误。所以空单元格也要构造一个空文本块。4. 翻车现场飞书开放平台接入的坑我一个没落下这一章我专门讲接入飞书开放平台的踩坑经历因为热词里全是飞书开放平台异常、错误码、上传失败我基本都经历过一遍。4.1 权限不全导致 10003 报错第一次调试时我的应用只开了docx:document然后信心十足地调用创建文档接口返回10003 forbidden当时我以为是 token 问题反复检查 App ID 和 Secret折腾了半小时。后面去开放平台后台看才发现创建文档还需要drive:drive或者drive:file中至少一项权限用来确定文档存放在哪个目录。这类问题的排查技巧是不要只盯着错误码。飞书的报错信息里通常会带一个debug_info字段字段里会写具体缺哪个 permission。把响应体完整打印出来就能少猜很多东西。resp_json resp.json() print(resp_json.get(debug_info) or resp_json)4.2 Token 缓存与并发冲突飞书的tenant_access_token有效期默认两小时而且同一时间申请太多会被限流。我的初次实现是每次调用接口前现取一次 token结果文档块超过 100 个时批量写入接口反复重试token 被并发请求打得失效返回 9999 甚至 1002。后面加了全局缓存token_cache {token: None, expire_at: 0} def get_token() - str: if token_cache[expire_at] time.time() 60: return token_cache[token] resp requests.post(...) token_cache[token] resp[token] token_cache[expire_at] time.time() resp[expire] - 60 return token_cache[token]多留 60 秒余量是避免拿到的 token 在边缘时刻才失效导致写入中断。4.3 实测遇到的错误码速查表错误码含义我的处理方式10003权限不足或应用未发布后台补权限重新发布应用版本10002Token 无效或过期检查缓存逻辑确认用的是 tenant_access_token9999后台服务繁忙/未知错误指数退避重试间隔 1s/2s/4s1005图片上传失败压缩图片、转格式、检查文件名编码120006文档不存在或无权访问检查 folder_token 是否放入了正确的父目录10024块的 children 超过接口限制拆分成多次批量写入其中 10024 我很想吐槽一下。飞书批量创建块接口对单次 children 数量有限制我实测在 100 到 200 之间就会不稳定。最后的解法是写了个分批写入函数每 80 个块一批全部写入后通过文档的块树接口确认结果。4.4 机器人发表格 vs 文档里嵌表格热词里有飞书机器人发送表格这里也说清楚我的取舍。飞书机器人发消息时如果你想发一个像样的表格选项很有限发送富文本卡片里面用 markdown 模拟表格但列宽、对齐完全不可控发送图片先渲染成 PNG 再发视觉效果好但不可复制发送多维表格记录链接适合数据量大的结构化数据但需要预先建好数据表。我的场景是文档发布不是在聊天窗口里发消息所以我选择把表格转成文档内部的表格块。这个决定让整个项目简单了很多文档编辑器的表格天然支持行列操作用户可以在飞书里直接改表格不用回到 Markdown 源文件。5. 效率与边界一篇带 20 张图片的文档到底要多久5.1 实测数据我拿自己的一篇技术复盘文章做了基准测试这篇文档的规模是正文 3800 字、22 张本地图片、6 个表格、4 个代码块。整个流程耗时阶段耗时解析 Markdown0.3s图片压缩与格式转换2.1s22 张图片上传4.8s创建文档 批量写入1.2s合计约 8.4s相对我之前手动复制粘贴的四十分钟提升了两个数量级。这里要特意说明图片上传是最耗时的因为飞书接口逐张上传无法并发。我试过开多线程并发上传但飞书对同一应用的并发上传有限流线程一多反而连续 9999。最终的稳定方案是单线程串行上传配合超时重试速度虽然慢一点但稳。5.2 Markdown 覆盖度这个插件不是我吹的全语法支持事实上我刻意做了范围控制Markdown 元素是否支持说明ATX 标题支持映射到 heading1-6段落纯文本支持自动合并连续行粗体/斜体/行内代码支持解析 text_run 样式无序/有序列表支持映射到 bullet/ordered 块引用块支持支持嵌套层级围栏代码块支持支持语言标注表格支持转飞书表格块LaTeX 公式部分支持需要额外转成飞书公式块HTML 块不支持原样丢弃内嵌 iframe 等富媒体不支持手动处理LaTeX 公式这块飞书文档本身有公式块但转起来比较麻烦需要把$...$和$$...$$分别识别再映射到公式块结构。因为我的历史文档里公式占比不高这个功能我排在了第二版目前版本会原样保留为文本。5.3 达不到秒传的场景说实话我不太喜欢说秒传因为那有点营销味。这个工具真正的价值是批量发布和无人值守。但有些场景它确实扛不住超大文档超过 5000 个块的文档批量写入时要分批速度会慢到半分钟以上而且飞书编辑器对大文档的渲染本身也存在性能瓶颈大量动态图片动图上传后飞书只是一个静态预览动效会丢失如果团队需要看完整 GIF建议走图床外链依赖在线阅读器的 Markdown如果文档里有很多 html代码块或者#tag 标签飞书原生组件无法表达这些语义。遇到这些场景我的建议是不要追求 100% 转换接受核心内容无损边缘信息手工补的原则。6. 复盘Codex 在哪里帮我省了时间哪里反而是拖累6.1 Codex 的强项这轮开发里Codex 帮我省了最多时间的地方不是生成大段代码而是处理飞书 API 文档这件事。飞书的文档结构散、权限说明藏在底层字段里很多错误码光靠搜论坛很难定位。我直接把接口报错 JSON 丢给 Codex它能结合错误字段的语义去查文档、推测原因、改写参数比我自己翻网页快得多。示例迭代也很舒服。我给它指定一个测试 Markdown 文件让它把输出 JSON 和预期结构对比一遍遍改很快就把嵌套列表和表格的细节磨对了。6.2 Codex 的弱项和困惑Codex 最大的问题是它偶尔会过度自信。比如它会给飞书接口编一个根本不存在的字段folded_group_block或者把图片上传的parent_type写成错误的枚举值。这些错误光看编译结果发现不了必须实际调用接口才会暴露。所以我的原则是Codex 生成的代码每一处飞书 API 调用我都要对着响应 JSON 验证一遍。另外Codex 会陷入局部最优当你让它修一个图片上传错误它可能只改上传函数却忽略了解析阶段图片路径已经错了。这种跨模块的问题只能靠人读一遍调用链来发现。6.3 我会继续怎么扩展这个插件目前只解决 Markdown 到飞书的单向发布接下来我想做两件顺理成章的事。一是支持网页转 Markdown 再转飞书的全链路。热词里我看到有人在搜agent 将网页保存成 markdown 的 skill我打算给 Codex 再配一个 skill先把网页正文抓下来保存成 Markdown带图片落地再走md2feishu发布。这样遇到好文章我可以在 Codex 对话里一句话完成收藏 发布到知识库。二是加一个更新已有文档的能力。目前是每次创建新文档如果我想重发同一篇会在飞书里留下很多重复文档。我准备按文档标题加 tag再调用docx/v1/documents/{document_id}/blocks清空重建把更新流程做成幂等操作。最后分享一个实在的小技巧把md2feishu绑定的命令行入口放到 Codex 的AGENTS.md里时一定要写清楚路径参数必须由用户提供禁止猜测。我第一次测试时Codex 自作主张把当前目录下的 README.md 传上去了结果我的飞书知识库多了一篇不是我本意的文档删起来还挺麻烦。归根结底工具能替代的是重复劳动但要发什么、发到哪、以什么口径发这个决策还是应该牢牢握在自己手里。