一次飞书剪藏图片丢失排查:为什么只有第一张图成功,其他全挂了?

发布于 2026年03月29日 07:27

#CLI#Feishu

前两天我遇到一个很典型,但也很容易被忽略的问题。

我用飞书的 Chrome 剪藏插件剪一篇自己博客里的文章,结果很诡异:

只有第一张图片成功了,后面的图片全没了。

文章正文还在,结构也正常,但大量配图直接消失。

一开始我以为是飞书剪藏插件对 localhost 支持不好,或者是 Astro 渲染的问题。结果一路查下来,发现问题根本不在飞书,也不在博客页面本身,而是在我自己的图片下载链路里。

准确地说,是我一个给飞书图片做本地化的 skill,把图片扩展名写死成了 .webp

这篇文章就把整个排查和修复过程完整记录一下。

先看现象:为什么“只有第一张图成功”?

当时剪藏的是我博客里一篇关于飞书 CLI 的文章。

现象很简单:

  • 第一张图能正常进入飞书文档
  • 后面的正文图片大面积丢失
  • 文章正文文本、标题、结构都没问题
  • 页面本地访问正常,浏览器里图片也都能显示

这种问题最烦的地方在于:

浏览器里一切正常,不代表下游系统也能正常处理。

网页能显示,只说明浏览器足够宽容;但剪藏插件和飞书后端处理图片时,链路会严格得多。

第一步:先怀疑剪藏插件

我先看了飞书 Chrome 剪藏插件当前目录下的实现。

重点查的是三段逻辑:

  1. 页面内容提取
  2. 图片抓取和转 base64
  3. 上传 HTML 给飞书

最后定位到它的核心流程大概是这样的:

fetch(imgSrc, { mode: "cors" })
  -> blob()
  -> FileReader.readAsDataURL()
  -> 把 data URL 塞回 HTML
  -> 上传给飞书

也就是说,插件会主动把页面里的每一张图抓下来,转成 base64,再提交给飞书。

这时候我就意识到一个关键点:

如果图片本身的 MIME 和扩展名不一致,这条链路就有可能出问题。

因为浏览器直接显示图片,很多时候靠的是“内容嗅探”;但插件转 blob、转 data URL、再交给飞书时,系统更依赖 MIME。

第二步:排除博客页面结构问题

接下来我去看这篇文章的实际渲染结果。

重点检查了几件事:

  • 图片是不是普通 <img src="...">
  • 有没有 srcset
  • 有没有懒加载占位图
  • 有没有 Astro 的图片组件额外改写
  • 有没有 picture/source 这种多层结构

结果很明确:

文章正文里的图片输出非常干净,就是普通的 <img src="/images/blog/...">

没有 srcset,没有奇怪的懒加载替换,也不是灯箱脚本改坏了 DOM。

这基本可以排除“页面结构导致剪藏失败”。

第三步:检查图片文件本身

然后我直接去检查文章引用的图片源文件。

结果一看就破案了。

我这篇文章里的正文图片在 Markdown 里全写的是:

![](./imgs/lark-cli-open-source-tutorial-01.webp)
![](./imgs/lark-cli-open-source-tutorial-02.webp)
...

但我用 file 去看真实格式,发现它们根本不是 WebP。

实际情况是:

  • *.webp 文件,真实内容全是 PNG
  • 封面 *.png 文件,真实内容其实是 JPEG

也就是说,当时我的图片资源处于这种状态:

  • 文件名:xxx.webp
  • 实际内容:PNG

或者:

  • 文件名:cover.png
  • 实际内容:JPEG

浏览器能显示,是因为浏览器很宽容。

但飞书剪藏插件走的是:

URL -> fetch -> blob -> data URL -> 上传

这时候如果拿到的 MIME 是 image/webp,但里面其实是 PNG 字节流,下游处理就很可能挂。

于是就出现了表面上看很迷惑的现象:

第一张图侥幸成功,后面很多图全部失败。

根因不在飞书,而在我自己的图片下载 skill

继续往回追,我发现这些图片不是手工处理的,而是我之前通过一个 skill 下载飞书图片时生成的。

这个 skill 叫:

feishu-image-downloader(飞书图片下载 Skill)

它的职责很简单:

  • 从 Markdown 里提取飞书图片链接
  • 下载到本地
  • 替换文章里的图片引用

问题就出在这里。

我看了它当时的实现,发现有两个致命点。

问题 1:文件扩展名被写死成 .webp

下载脚本里,文件名是这样生成的:

filename=$(printf "%s-%02d.webp" "$post_name" "$index")

不管飞书返回的是 PNG、JPEG 还是 WebP,统统保存成 .webp

这就埋下了雷。

问题 2:Markdown 替换逻辑也写死成 .webp

Python 脚本在替换 Markdown 引用时,也是这样写的:

new_link = f'![]({relative_path}{post_name}-{local_counter:02d}.webp)'

也就是说,不仅磁盘文件名固定成 .webp,连文章里的引用也固定成 .webp

这意味着:

只要下载回来的真实内容不是 WebP,整个文章就会进入“扩展名和真实格式不一致”的状态。

真正的修复方式:从源头修,而不是事后补救

我这次没有只修文章本身,而是分成两层去修。

第一层:修 skill 的源头逻辑

我改了两个地方。

1. 下载脚本不再写死 .webp

新的逻辑是:

  • 先下载到临时文件
  • file --mime-type 判断真实格式
  • 再映射成正确扩展名:
    • image/png -> .png
    • image/jpeg -> .jpg
    • image/webp -> .webp
    • image/gif -> .gif
    • image/svg+xml -> .svg

也就是说,现在下载链路变成了:

下载临时文件 -> 识别 MIME -> 决定扩展名 -> 正确落盘

2. Markdown 替换时读取真实下载结果

以前替换逻辑是假设:

第 1 张图一定叫 post-01.webp
第 2 张图一定叫 post-02.webp

现在不是了。

现在会先扫描输出目录,找到实际生成的文件名,再回写 Markdown:

  • post-01.png
  • post-02.jpg
  • post-03.webp

这样下载结果和文章引用天然一致。

第二层:修这篇已经出问题的文章

源头修好以后,我又把这篇已经出问题的文章实际修了一遍。

具体做法是:

  • 检查 src/content/blog/2026-03/imgs 下所有图片
  • 找出“扩展名与真实格式不一致”的文件
  • 把文件名改成正确扩展名
  • 同步修改 Markdown 里的所有引用

最终修正结果是:

  • 封面:cover.png -> cover.jpg
  • 正文:01.webp ~ 23.webp -> 01.png ~ 23.png

也就是说,之前是“假 WebP”,现在变成“真 PNG / 真 JPEG”。

为什么这个问题特别容易被忽视?

因为它具备三个很容易误导人的特征。

1. 浏览器里看起来一切正常

浏览器对图片格式兼容非常宽。

很多时候你文件名写错了,浏览器照样能渲染。

这会让人误以为资源完全没问题。

2. 静态站点构建也不一定报错

构建工具通常只关心文件能不能复制、引用能不能解析。

它不会帮你验证:

这个 .webp 文件里到底是不是真 WebP。

3. 真正炸的是下游链路

像飞书剪藏、内容转存、图片代理、CDN 重写、格式转换这种链路,对 MIME 更敏感。

所以问题不会在你自己页面爆炸,而是在“被别人消费时”爆炸。

这类问题本质上属于:

资源在上游处于“脏数据”状态,但前端浏览器帮你把错误掩盖掉了。

这次我得到的几个结论

1. 图片文件名不是装饰,它是契约

扩展名写成什么,不能靠猜。

如果你保存成 .webp,那它最好就真的是 WebP。

否则一旦进入转码、代理、剪藏、上传链路,迟早出事。

2. 下载工具不要假设格式

凡是做“远程资源下载到本地”这类工作,都应该以后端真实返回的 MIME 为准,而不是预设扩展名。

尤其是自动化工具、CLI、skill、批处理脚本,最怕这种“先写死,后来忘了”的小设计。

3. 浏览器的宽容会掩盖数据问题

网页显示正常,不等于资源规范。

真正靠谱的做法是:

  • 抽样跑 file
  • 检查 MIME
  • 检查构建后的静态资源
  • 检查会被下游系统消费的链路

4. 修 bug 最好的方式,是顺手修掉 bug 的生产机制

如果我只是把这一篇文章的图片手工改对,下次再跑 skill,问题还会复现。

所以这次真正有价值的,不是“修好了一篇文章”,而是:

把制造错误资源的那条链路一起修了。

最后

这次问题表面上看,是“飞书剪藏为什么只成功了一张图”。

但真正的答案其实是:

不是飞书只成功了一张图,而是我之前一直在生产格式不规范的图片资源,只是以前没有暴露出来。

很多 bug 都是这样。

你以为你在修一个显示问题,最后发现你修的是一个数据契约问题; 你以为问题出在插件,最后发现问题出在自己的工具链; 你以为是兼容性,最后其实是输入不干净。

说到底,工程里的很多诡异现象,不是系统太复杂,而是某个地方偷偷“糊弄了一下”,然后一路传染到了后面。

这次是图片扩展名。

下次可能就是 MIME、编码、时区、换行符、富文本结构、甚至一个多余的空格。

但套路都一样:

别只修结果。顺着链路,把问题生产出来的地方一起修掉。

评论互动

© 2026 王若风的技术博客 · Powered by Astro