从项目记录回看,这个小程序的核心开发集中在两天内。
第一天,我完成了技术路线验证、后端服务、小程序页面、PDF/Word 生成以及本地和云端的初步验证;第二天,我又连续处理了云托管原生调用、审核信息、网络重试、大文件下载和短文档兼容等问题。
这次经历让我重新认识了一件事:做出一个“能演示”的小程序并不难,难的是让它在真实环境里稳定完成最后一步。
一、我到底想解决什么问题
需求非常直接:
用户复制一个无需登录即可阅读的飞书知识库或文档链接,粘贴到微信小程序中,选择 PDF 或 Word,等待处理完成,然后把文件保存到手机。
我最初把产品流程压缩成了四步:
为了控制风险,我也在一开始就划定了能力边界:
只处理“互联网上任何人可阅读”的公开页面; 不接收用户的飞书 Cookie、Access Token 或账号密码; 不模拟登录,不绕过组织权限、密码和付费限制; 导出前要求用户确认自己拥有复制和导出权利; 页面内容和生成文件只做临时处理,到期自动清理。
这个边界看似限制了产品能力,实际上帮我减少了大量身份认证、账号安全和隐私存储问题,也让后续架构始终保持清晰。
二、第一步不是写页面,而是验证技术路线
这个项目最关键的决策,并不是按钮用什么颜色,而是:文件究竟应该怎样生成?
摆在面前的方案大致有三种:
调用飞书官方导出接口; 用浏览器打开页面后直接“打印为 PDF”; 读取公开页面的结构化内容,再自行生成 PDF 和 Word。
我先对一个同类工具生成的 PDF 做了黑盒分析。
样本文件的 Creator 和 Producer 指向 PDFKit,页面是固定的 Letter 尺寸,字体被统一嵌入,正文图片也经过了重新编码和排版。它不像飞书官方原生导出,也不像 Chromium 打印出来的 PDF,更像是“读取文档内容—转换成中间结构—自行排版生成文件”。
接着我检查了飞书公开页面。初始 HTML 中可以找到标题、文档 token、公开权限状态、首批文档块以及图片资源地址。但继续观察后,我发现了真正的难点:飞书长文档使用了虚拟化渲染。
也就是说,DOM 里并不会同时存在整篇文档。页面向下滚动时,旧内容会被移出,新内容才会进入可视区域。如果只请求一次 HTML,或者只读取当前 DOM,长文档的后半段一定会丢失。
至此,技术路线基本确定:
用匿名、隔离的浏览器打开公开页面,自动滚动并持续收集文档块,统一转换成一份中间文档模型,再分别交给 PDF 和 Word 渲染器。
这一步很重要。它让我在正式开发前先验证了最不确定的部分,避免把时间花在一个最终无法闭环的界面原型上。
三、真正的系统,不只是一张小程序页面
最后形成的完整链路是:
微信小程序 → Node.js API → 匿名 Chromium → 文档中间模型 → PDF/Word 渲染 → 临时下载地址 → 微信本地文件
小程序端保持得很轻,只负责链接输入、格式选择、状态展示和文件打开;复杂工作全部放在服务端完成。
服务端则被拆成了几个相对独立的部分:
链接解析:识别 wiki、docx 和旧版 docs 链接; 权限预检:确认页面确实可以匿名读取; 内容抓取:滚动虚拟化页面,合并正文、标题、列表、链接和图片; 图片处理:下载公开图片,限制数量与体积,并统一转换格式; 文件渲染:使用 PDFKit 生成 PDF,使用 docx 生成 Word; 任务管理:排队执行、报告进度、生成短期下载地址并按时清理文件。
我没有让 PDF 和 Word 各自理解一遍飞书页面,而是先建立统一的中间文档模型。一段内容在模型里是什么类型、有哪些行内样式、是否包含图片,只解析一次;PDF 和 Word 只关心怎样把它排出来。
这是这次项目中最值得保留的架构决策之一。它降低了两个渲染器之间的重复逻辑,也让后续增加新格式成为可能。
四、最难的不是“抓到”,而是“抓完整”
为了处理飞书的虚拟化页面,我在无登录态的 Chromium 中自动滚动文档容器,每一轮都收集当前可见的文档块,再使用 data-record-id 或块 ID 去重合并。
抓取过程不能只判断“滚到底了”。因为页面高度和可见块会动态变化,图片也可能晚于文本完成加载。最终的停止条件需要同时考虑:
当前是否已经到达滚动区域底部; 连续多轮是否都没有发现新块; 图片块是否已经完成加载; 是否达到文档块数量或最大滚动次数上限。
图片又是另一层问题。有些图片节点已经出现,但真实地址还没有挂载;有些图片位于分栏布局中,需要保留相对宽度。我的处理方式是先等待一次图片水合,再结合块 token 补全资源地址,最后用同一匿名浏览器上下文下载。
下载后的图片不会直接原样交给渲染器,而是先经过白名单校验、单图大小限制、总大小限制和格式归一化。这样既能减少异常图片导致的解析问题,也能降低服务端请求伪造(SSRF)的风险。
这部分让我意识到:网页抓取最容易出现的假象,是“前几屏看起来完全正确”。如果测试样本只有短文档,就很难发现虚拟滚动、懒加载和内容去重中的问题。
五、生成文件,本质上是在重新做一次排版
抓到结构化内容之后,事情并没有结束。
PDFKit 和 DOCX 都不是把一个网页完整复制过去,而是根据中间模型重新排版。因此,我还需要处理:
中文字体查找和嵌入; 标题层级与字号; 正文行距和段间距; 有序列表、无序列表和待办项; 行内加粗、下划线、删除线与超链接; 单图缩放和多图分栏; 分页、页边距和页码; 文件名中的非法字符。
中文字体尤其容易在本地和容器之间出现差异。Windows 上可以找到微软雅黑,Linux 容器中则额外安装 Noto CJK 字体,并通过环境变量指定普通和粗体字体路径。
我没有只检查“文件是否成功生成”,还保留了本地 PDF、云端 PDF、本地 DOCX 和云端 DOCX 的验证产物。PDF 又被渲染成逐页图片,并检查首、中、尾页和整册缩略图。
自动化测试可以确认文件头、接口状态和安全规则,但文字是否溢出、图片是否被截断、最后一页是否异常空白,仍然需要视觉验证。文件类产品里,“能打开”和“能交付”之间还有一段距离。
六、第一次大改:从普通 HTTP 请求切到云托管原生调用
最初的小程序通过 wx.request 和 wx.downloadFile 直接访问一个 HTTPS API 地址。
部署到微信云托管后,我把通信方式改成了平台原生的 wx.cloud.callContainer:小程序启动时初始化云环境,每次请求都明确指定环境 ID 和服务名,后端仍然保持原来的 HTTP API 结构。
这次调整带来的启发是:后端接口设计可以保持稳定,但客户端的传输层必须适配实际运行平台。越早把本地地址换成真实云调用,越早能发现那些只在真机和云环境中出现的问题。
七、最典型的坑:导出成功了,文件却到不了手机
整个项目最有代表性的故障发生在最后一步。
服务端已经成功生成文件,任务状态也变成了完成,但小程序一次性通过云调用接收完整二进制文件时,容易遇到超时或响应体限制。对于几百 KB 的文件可能没问题,换成长文档和多图 PDF 就不稳定。
我先把下载超时从 60 秒放宽到 120 秒,但很快意识到,这只是推迟失败,并没有改变传输模型。
最终方案是把文件切成固定的 256 KB 分片:
与此同时,我只对 GET 请求增加了 600 毫秒、1.5 秒和 3 秒的渐进重试。查询任务和下载分片天然适合重试,而创建导出任务是有副作用的 POST 请求,贸然自动重试可能产生重复任务。
这个区分非常重要:重试策略不应该只看“网络失败了”,还要看这个操作是否幂等。
这次问题也让我记住了一句话:
对文件类小程序来说,下载不是收尾工作,而是核心业务链路的一部分。
八、一个短文档,暴露了我对“正常文档”的错误假设
长文档跑通后,我又遇到了一个很反直觉的问题:短文档反而无法导出。
原因是抓取器在等待页面就绪时,不仅要求出现文档块,还要求页面具有明显的可滚动高度。这个条件对长文档成立,对一屏就能展示完的短文档却永远不成立,最终只能等待超时。
修复方式很简单:页面就绪只判断文档块是否已经水合,不再把“必须可以滚动”当作必要条件。没有滚动空间的文档,同样是一份合法文档。
同一次修复中,我还兼容了飞书匿名访问过程中出现的受控账号域跳转,但只放行指定飞书/Lark 账号域名下的 /accounts/ 路径,没有把网络白名单整体放宽。
这个 bug 给我的教训很直接:
不要把测试样本的特征,误写成产品规则。
长文档需要滚动,不代表所有合法文档都必须能滚动。以后设计真实样本集时,至少要同时覆盖短文、长文、纯文本、多图、分栏图和异常权限页面。
九、审核准备不是最后补一句隐私声明
功能跑通后,我又专门调整了一次小程序的发布审核信息。
页面原来只有一句“仅处理公开页面”,后来改成了更完整的隐私与数据处理说明:为什么要临时处理公开链接和页面内容、文件大约多久删除、工具支持什么权限范围,以及它是一个独立效率工具。
同时,产品流程中保留了“我确认有权导出该公开文档,并仅作合法用途”的主动勾选。
当然,一段说明不能代替完整合规工作。真正商业上线,还需要补充隐私保护指引、服务条款、内容投诉入口、审计记录和更明确的数据保存策略。
但这次实践让我确认:合规边界越早进入产品流程,后面需要推翻的设计就越少。
十、这次做对了什么
回看整个项目,我认为有五个决定非常关键。
第一,先验证最不确定的技术假设。先通过样本 PDF 和公开页面确认技术路线,再开始写完整系统,避免在错误方向上做精致界面。
第二,用中间模型隔离数据来源和输出格式。飞书页面怎样变化,与 PDF/Word 怎样排版,被拆成了两个问题。
第三,把长任务设计成异步任务。创建任务、轮询进度、完成后下载,比让一个 HTTP 请求一直等待三分钟更适合小程序和容器环境。
第四,安全限制从第一版就进入代码。HTTPS、域名白名单、资源体积、并发、速率、任务 TTL 和用户授权确认,不是上线前才补的装饰。
第五,用真实文件和真实环境验证。单元测试、本地导出、容器构建、云端导出、逐页渲染和手机打开,各自发现的是不同层次的问题。
十一、如果重新做一次,我会怎样安排
如果重新开始,我会把过程明确拆成四个阶段。
第一阶段只做技术可行性验证:用一长一短两份公开文档,证明标题、正文和图片可以被完整读取。
第二阶段建立统一文档模型,同时完成一个最小 PDF 渲染器。这个阶段不追求样式,只追求内容完整和顺序正确。
第三阶段再做小程序闭环,并且第一天就使用真实云托管调用和分片下载,不再先假设整文件传输一定可行。
第四阶段建立固定回归样本集,覆盖权限、长度、图片数量和复杂块类型;每次修改抓取器后,自动对比块数量、标题、图片数量和输出文件基本信息,再进行视觉抽检。
这样的顺序会比“先把页面做漂亮,再逐层接后端”更稳,也更容易定位问题。
十二、项目现在到了哪里
目前,这个项目已经形成了可提交审核的完整工程形态:
微信原生小程序界面; Node.js + Express 导出服务; Playwright 匿名页面读取; PDFKit 和 DOCX 双格式渲染; Docker 容器与中文字体环境; 云托管原生调用; 异步任务、进度轮询和 256 KB 分片下载; 链接、权限、文件生成和分片接口测试。
但它仍然不是一个“支持所有飞书内容”的万能导出器。表格、多维表格、公式、思维导图、画板、音视频、附件和第三方嵌入,都还无法保证完整还原。飞书网页 DOM 也不是稳定的公开 API,页面升级后可能需要同步调整。
如果继续向正式产品推进,下一步应该是持久化任务队列、对象存储签名链接、失败率监控、真实文档回归样本、审计日志和投诉处理机制。若使用场景变成企业内部文档,则应优先切换到飞书官方 OpenAPI,而不是继续依赖公开页面解析。
写在最后
这个项目表面上是在做“飞书文档转 PDF/Word”,实际上让我完整经历了一次小产品从想法到可交付工程的过程。
真正耗费时间的,往往不是最显眼的功能,而是那些藏在链路末端的细节:虚拟滚动会不会漏内容,中文字体在容器里是否存在,短文档为什么一直等待,云调用为什么收不到完整文件,网络抖动后哪些请求可以安全重试。
一个工具的价值,不在于演示时按钮有没有反应,而在于用户粘贴自己的链接、使用自己的网络、导出自己的文件时,它还能不能把事情做完。
这也是我做完这次小程序后,最想记录下来的一点。
夜雨聆风