
1. 先把需求拆清楚:什么样的文档提取才算“能用”Python 做钉钉文档的自动化解析与内容提取,这件事听起来像是“调个接口、拿回 JSON、转成 Markdown”三步走,但真正落过地的人都知道,难点从来不在“调通接口”,而在“提取出来的东西能不能直接用”。我见过太多脚本,跑是跑通了,导出来的文档标题层级全乱、表格错位、图片变成一堆失效链接、代码块里的缩进被吃掉,最后还得人工返工一遍,自动化等于白做。所以在写第一行代码之前,先把需求拆明白,比急着申请权限重要得多。钉钉文档本质上是块(block)结构的富文本载体,一份文档不是一个 HTML 字符串,而是一棵由若干内容块组成的树,每个块有自己的类型、层级、行内元素集合和子块引用。Python 要做的核心工作,是把这棵树“翻译”成目标格式。翻译得准不准,取决于你对块模型的理解决到什么程度。这篇内容面向的是有基础 Python 能力、需要批量处理文档的开发者,也会把新手容易卡住的鉴权、限流、增量同步这些环节讲透,争取让没接触过这块的人照着也能跑出第一版。1.1 四类典型场景与各自的硬指标不同的使用目的,对“解析质量”的要求完全是两个量级。先对号入座,再决定投入多少精力。场景典型诉求硬指标可以妥协的地方知识库迁移把文档搬到自建 wiki 或静态站标题层级、代码块、表格必须 100% 准确样式、颜色、批注可以丢内容归档备份定期冷备,防止误删完整性优先,能还原原文格式可退化为纯文本语料抽取喂给检索或模型做训练语料正文干净,去掉附件噪声表格可扁平化数据统计统计文档数量、字数、更新频率元信息准确正文可以只要摘要我个人的判断标准很朴素:迁移类项目,渲染结果要能和原文档肉眼对齐;归档类项目,只要能在需要的时候找回内容就算合格。这两者的代码复杂度差三倍以上,先想清楚属于哪一种,能省掉大量无用功。很多人一上来就追求“完美还原”,结果卡在合并单元格和嵌套列表上出不来,项目直接烂尾。还有一个容易被忽略的指标是幂等性。同一份文档解析两次,输出必须完全一致,否则增量同步时你会误判成“内容有变化”,反复重跑。这一点在后面的指纹机制里会详细说。1.2 三条技术路线怎么选摆在面前的路线其实只有三条,差异很大:走开放平台的服务端接口。这是唯一推荐的正路。拿应用凭证换 token,按文档 ID 拉取块列表,数据结构稳定、字段有文档、支持分页。缺点是必须先创建企业内部应用、申请对应权限,而且不同文档类型的接口不完全一样。模拟登录抓网页。早期不少人这么干,但页面是前端渲染的,结构随版本变动,登录态维护成本高,还容易触发风控。这条路我只在“只需要一次性导几篇、且没有应用权限”的极端情况下考虑过,长期跑绝对不建议。让用户手动导出再解析。钉钉文档支持导出为 Word 或 PDF,Python 用python-docx或 PDF 解析库二次处理。省事,但完全谈不上“自动化”,量一大就废。选型逻辑很简单:有权限就走接口,没权限就去申请权限,不要用抓页面来绕。抓页面带来的维护成本,三个月后一定会以“半夜报警”的形式还给你。我在一个内部工具上吃过这个亏,前端改版一次,解析逻辑全线崩,修了两天才恢复,而那两天所有依赖它的下游任务全在排队。提示:申请权限时不要只盯着“读取文档内容”这一项。实际落地往往还需要通讯录读取(用于映射文档所有者)、文件下载(用于取图片和附件)、以及空间或知识库的列表权限。一次性把清单列全,避免反复提审。1.3 整体分层架构把系统拆成四层,后面每一层都可以单独替换和测试,这是我在多个类似项目里验证过最省心的结构:接入层:负责 token 获取与刷新、HTTP 会话复用、统一重试与限流。解析层:把接口返回的块数组还原成中间表示,一个与平台无关的文档树对象。渲染层:中间表示到 Markdown / HTML / 纯文本的转换器,一种输出一个类。调度层:任务队列、增量判断、断点续传、结果落库。分层的最大好处是解析层可以离线测试。把几份典型文档的原始 JSON 存成本地 fixture,不用每次都请求接口就能跑单测,改渲染逻辑时特别爽。这一点后面在“解析正确性校验”里还会展开。2. 环境准备与鉴权:把“进门”这一步做扎实鉴权是整条链路里最容易出“看起来能跑、跑久了就挂”的环节。核心问题就两个:token 什么时候过期,以及多个进程并发时怎么共享 token。我见过最典型的错误写法是每次请求都重新申请一次 token,单机跑没问题,一上多线程并发,直接被限流打回。2.1 应用创建与权限颗粒度在开放平台侧创建企业内部应用之后,会拿到一组应用凭证,通常是一个标识加一个密钥。这组东西的保管原则只有一条:只放在服务端环境变量或密钥管理服务里,绝不进代码仓库,绝不进前端。我见过把密钥硬编码在脚本里、然后脚本被传到公共仓库的事故,补救起来非常麻烦。权限申请要按最小必要原则来,但也不能抠过头。实操中的经验是分两批申请:第一批是“读文档元信息 读文档块内容 下载文件”,第二批是“读空间列表 读成员信息”,第二批等主流程跑通再补。一次申请太多,审批链路长,反而拖慢进度。还需要注意一点:应用的可见范围。如果应用只授权给了部分成员,那它只能读这些成员有权限的文档。批量任务跑到一半大量返回“无权限”,八成是这里没配好,而不是代码问题。2.2 access_token 生命周期管理服务端接口的 token 一般有效期在小时级别,返回体里会带一个过期秒数。正确的做法是做一个带锁的缓存单例:取 token 时先看缓存,剩余有效期低于阈值(比如 300 秒)才去刷新,刷新过程加线程锁,避免并发请求同时触发多次刷新。import threading import time import requests class TokenHolder: def __init__(self, app_key, app_secret, base_url): self._key app_key self._secret app_secret self._base base_url self._lock threading.Lock() self._token None self._expire_at 0.0 self._session requests.Session() def get(self): # 提前 300 秒刷新,避免临界点请求带着过期 token 出门 if self._token and time.time() self._expire_at - 300: return self._token with self._lock: if self._token and time.time() self._expire_at - 300: return self._token resp self._session.post( f{self._base}/auth/access_token, json{appKey: self._key, appSecret: self._secret}, timeout10, ) resp.raise_for_status() data resp.json() self._token data[access_token] self._expire_at time.time() int(data.get(expires_in, 7200)) return self._token这段代码里有两个细节值得说。双重检查(先无锁判断、加锁后再判断一次)是为了让高频调用时绝大多数请求走无锁路径,只有真正需要刷新时才进入临界区。提前 300 秒刷新是因为网络请求本身有耗时,如果卡在过期前一秒去用,拿到手可能已经失效了,尤其是批量任务里一次要发几十个请求的场景。令牌千万不要打印到日志里。调试时想看,就打印前六位加后四位做脱敏,否则日志一旦外泄,等于把整个文档库的钥匙交出去。2.3 统一请求层:限流、重试、日志所有请求必须走同一个封装函数,这一点没有商量余地。原因很实际:你要在一个地方同时解决限流、重试、错误码归一化和耗时统计。散落在各处的requests.get会让这些问题变成噩梦。重试策略要区分错误类型。网络超时、连接重置这类可以重试;返回“参数错误”“文档不存在”这类就不能重试,重试多少次都是同样结果,只会浪费配额。返回限流错误时要退避,而不是立刻重发。import random import time RETRYABLE_STATUS {429, 500, 502, 503, 504} def call_api(session, method, url, token_holder, **kwargs): max_attempts 5 for attempt in range(1, max_attempts 1): headers kwargs.pop(headers, {}) headers[x-acs-dingtalk-access-token] token_holder.get() try: resp session.request(method, url, headersheaders, timeout(5, 30), **kwargs) except (requests.Timeout, requests.ConnectionError): if attempt max_attempts: raise time.sleep(min(2 ** attempt, 20) random.random()) continue if resp.status_code in RETRYABLE_STATUS: if attempt max_attempts: resp.raise_for_status() # 优先尊重服务端给出的等待时间 wait float(resp.headers.get(Retry-After, 0)) or min(2 ** attempt, 20) time.sleep(wait random.random()) continue resp.raise_for_status() return resp.json() raise RuntimeError(unreachable)指数退避加上随机抖动是关键。如果一百个并发同时被限流,同时按 2、4、8 秒退避,它们会在同一毫秒集体重发,再次触发限流,形成共振。加个 0 到 1 秒的随机数就能把请求打散。这个技巧在任何分布式调用场景都适用。3. 文档结构解析:从块模型到 Markdown这一节是整篇的核心。理解了块模型,后面的所有事情都顺;理解不了,就只能靠试错拼凑,遇到没见过的块类型就崩。3.1 文档元信息与文档树遍历一份文档的返回结构通常是:一个文档节点,下面挂着一串子块 ID,每个子块再带自己的类型和内容。遍历方式是广度优先或深度优先都可以,但必须处理父子关系,因为缩进层级、列表嵌套全靠这个。我习惯先把原始块列表转成字典索引{block_id: block},再做一次树构建。这样做的原因是:块列表中父子关系是通过 ID 引用的,如果每次都线性查找父节点,复杂度是 O(n²),几千个块的文档就会明显变慢。def build_tree(blocks): index {b[id]: dict(b, children[]) for b in blocks} roots [] for b in index.values(): parent_id b.get(parentId) if parent_id and parent_id in index: index[parent_id][children].append(b) else: roots.append(b) return roots遍历时一定要做防御性判断:父 ID 指向的节点不存在、循环引用、同一 ID 出现两次,这些在真实数据里都出现过。加个已访问集合就能避免死循环,否则脚本会静默卡死,排查起来极其痛苦。3.2 块类型映射:一张表决定成败块类型是解析的骨架。下面这张表是我在实践中沉淀下来的映射关系,不同平台命名略有差异,但语义基本一致:块类型语义转 Markdown易错点paragraph普通段落直接输出文本空段落要跳过,否则满屏空行heading标题#到######层级超过 6 要降级处理bulletList / orderedList列表-或1.嵌套靠父链计算缩进codeBlock代码块三反引号包裹语言标识可能为空quote引用多层引用要累加符号table表格Markdown 表格列宽不一致、合并单元格image图片![]()链接有时效attachment附件链接列表需要单独下载divider分割线---无callout高亮块引用块降级图标和颜色丢失真正麻烦的不是这些“标准款”,而是未知类型。我的处理原则是:未知类型不丢弃,降级成纯文本段落,并在日志里打一条警告。丢弃会让内容静默消失,降级至少保住了文字。日志里的警告则提醒你去补充映射规则。3.3 行内元素与富文本还原块里装的是行内元素数组,每个元素有类型和内容。文本、加粗、斜体、删除线、行内代码、超链接、提及、公式,都属于这一层。还原的时候有个经典陷阱:相邻的同样式文本如果不合并,会生成**加粗****加粗**这种破碎的 Markdown,渲染器可能识别失败。解决办法是遍历时做一次归并:连续的同类型行内元素先合并成一段,再统一加标记符号。同时要注意标记符号和空白的关系,比如**加粗** 后面这种,如果内容末尾本身带空格,包进标记里就会变成**加粗 **,有些渲染器会不生效。稳妥做法是把尾部空格移到标记外面。超链接的处理也有讲究。如果链接文字和 URL 完全一致,直接输出裸 URL 更干净;如果不一致,输出[文字](URL)。提及 在迁移场景通常退化成纯文本名字,因为目标平台没有对应的用户体系。3.4 表格与合并单元格的坑表格是翻车率最高的部分,我几乎每个项目都要在这上面花时间。问题集中在两处:第一,列数不一致。不同行的单元格数量可能不同,Markdown 表格要求表头列数和其他行对齐。做法是先算出全表最大列数,不足的行用空单元格补齐。第二,合并单元格。块模型里合并信息通常通过行跨度、列跨度表达。Markdown 原生表格不支持合并,只能做降级:被合并覆盖的位置填同样的内容,或者填“同上”。我倾向于填重复内容,因为读者看得懂,而“同上”在机器处理时还得额外还原。def render_table(cells, rows, cols): grid [[ for _ in range(cols)] for _ in range(rows)] for c in cells: r, col c[row], c[col] text inline_to_text(c.get(elements, [])) for dr in range(c.get(rowSpan, 1)): for dc in range(c.get(colSpan, 1)): if r dr rows and col dc cols: grid[r dr][col dc] text header, *body grid lines [| | .join(header) |, | | .join([---] * cols) |] lines [| | .join(row) | for row in body] return \n.join(lines)单元格内容里如果本身含有竖线,必须转义成\|,否则表格结构直接裂开。这个细节我踩过,一份写正则表达式的文档,表格里全是|,导出来整个表格全乱套。3.5 图片附件下载与链接替换图片链接通常是带签名的临时地址,几十分钟后就失效。所以必须在解析完成的第一时间下载,不能只存 URL 留着以后用。这个坑极其隐蔽:当天测试一切正常,第二天再去看,图片全裂了。下载逻辑要注意三件事:一是并发下载但控制并发数,别把带宽打满;二是按内容哈希命名,天然去重,同一张图在多篇文档引用只存一份;三是下载失败不能中断整篇文档的解析,记录到失败清单里后续补。import hashlib import os def download_image(session, url, out_dir): resp session.get(url, timeout30, streamTrue) resp.raise_for_status() data resp.content digest hashlib.sha256(data).hexdigest()[:16] ext png path os.path.join(out_dir, f{digest}.{ext}) if not os.path.exists(path): with open(path, wb) as f: f.write(data) return fassets/{digest}.{ext}扩展名不一定可靠,从响应头的 Content-Type 判断更准。如果拿不到,稳妥起见可以用imghdr之类的方式嗅探文件头,总比统一按 png 存、结果浏览器打不开要强。4. 批量任务的工程化:并发、增量与断点单篇解析跑通只是及格线。真正体现功力的是几百上千篇文档批量跑的时候,系统能不能稳定、能不能重跑、能不能只跑变化的部分。4.1 并发模型与限流算法Python 这边,IO 密集任务用线程池就够了,不必上多进程。线程池大小不是越大越好,经验值是8 到 16之间,具体看接口的配额。超过配额反而因为频繁退避导致整体吞吐下降,这个现象叫“拥塞崩溃”,很反直觉但确实存在。更精细的做法是加一个令牌桶,把请求速率压在配额以下,比如配额是每秒 20 次,就限到每秒 12 次,留 40% 余量给突发。简单的实现用一个带锁的计数器加时间窗口就够,不必引入复杂框架。import threading class RateLimiter: def __init__(self, rate_per_sec): self.rate rate_per_sec self.lock threading.Lock() self.tokens rate_per_sec self.last time.monotonic() def acquire(self): while True: with self.lock: now time.monotonic() self.tokens min(self.rate, self.tokens (now - self.last) * self.rate) self.last now if self.tokens 1: self.tokens - 1 return need (1 - self.tokens) / self.rate time.sleep(need)这个实现简单但够用。注意time.monotonic()而不是time.time(),后者受系统时钟调整影响,极端情况下会出现负的时间差。4.2 增量同步:用版本号和指纹每次都全量重跑,一千篇文档要跑几个小时,完全不现实。增量判断有两层:第一层是元信息比对。文档列表接口一般会返回更新时间或版本号。存一张本地索引表,记录每篇文档的 ID、版本号、最后同步时间。版本号没变就跳过。第二层是内容指纹。有些平台版本号更新很频繁,但实际内容没变(比如只改了个标点或者有人打开看过)。这时候用块内容的哈希做二次判断更准:把所有块的 ID 和内容拼起来算md5,和上次比对,一致就跳过渲染和写盘。要注意:指纹计算要排除掉那些每次都变的字段,比如块的临时下载链接、服务器生成的时间戳。如果不排除,指纹每次都不同,增量就完全失效了。我第一次做的时候没注意这点,以为增量生效了,结果发现每次都全量跑,查了半天才发现是链接在变。4.3 断点续传与失败队列批量任务的现实是:它一定会中途失败。网络抖动、配额耗尽、某篇文档格式异常,任何一条都能让进程挂掉。所以必须做到中断后能接着跑。实现方式很土但很有效:每处理完一篇,就往一个结果文件里追加一行 JSON(文档 ID 状态 时间)。重启时先读这个文件,跳过已成功的。追加写比批量写安全,即使进程被强杀,最多丢最后一行的内容,前面的记录都在。失败的要单独进队列,记录失败原因和重试次数。重试次数超过阈值(比如 3 次)的进入“死信”清单,人工介入。千万不要无脑无限重试,尤其是那种因为文档本身格式问题导致的解析异常,重试一万次还是失败,只会把日志刷爆。注意:断点文件不要用单个大 JSON 反复读写。一千篇文档的 JSON 每次读进来改一改再写回去,磁盘 IO 和内存都是浪费,而且写到一半崩溃会直接损坏整个文件。用 JSONL(每行一条)最稳。4.4 落地存储:JSON 落库与全文检索解析结果存哪,取决于后续怎么用。三种常见选择:原始 JSON 落盘:保留完整字段,便于以后重新渲染。缺点是体积大,一千篇文档可能几个 G。Markdown 文件 目录结构:最直观,可以直接扔进静态站点生成器。缺点是不便于做结构化查询。数据库:元信息进关系库,正文进搜索引擎或向量库。我自己的做法是三者结合:原始 JSON 压缩存冷备,Markdown 存主目录,元信息和正文摘要进数据库。这样既能保证可回溯,又能快速检索。数据库表结构可以很简单:字段类型说明doc_idvarchar文档唯一标识titlevarchar标题versionint同步时的版本号fingerprintchar(32)内容哈希word_countint字数统计updated_atdatetime文档更新时间synced_atdatetime本地同步时间local_pathvarcharMarkdown 相对路径有了这张表,做“最近一周更新了哪些文档”“哪些文档超过半年没动过”这类统计,一句 SQL 就出来了。这也是很多团队做这个工具的隐性需求——不只是搬运内容,还要看清内容的状态。5. 常见问题排查实录这一节全部来自实际踩坑。我把最高频的几类问题整理成速查表,后面再挑几个展开讲排查路径。5.1 错误码与症状速查表症状大概率原因处理方向全部请求返回鉴权失败密钥错误、应用被停用、token 缓存串了环境核对密钥来源,清缓存重取部分文档无权限应用可见范围不含文档所有者调整应用授权范围返回限流错误并发过高、未加限流降并发、加令牌桶、尊重响应头等待时间返回内容为空但文档有内容分页没拉全、只取了根块检查分页游标和子块递归图片过一天全裂存了临时链接没下载立即下载并本地化表格错行合并单元格未展开、含竖线未转义展开网格、转义特殊字符代码块缩进丢失去空白逻辑误伤代码块内保留原始空白列表嵌套层级全平未沿父链计算深度递归时传递深度参数中文变成问号写文件时编码不对显式指定 utf-8增量从不生效指纹含易变字段排除链接与时间戳5.2 五类高频故障的处理路径故障一:批量任务跑到一定数量后开始大量失败。这个几乎都是配额问题。排查顺序是:先看失败时间点是否集中,再看失败的错误码是否统一。如果统一是限流,就降并发;如果不统一,八成是某几篇文档本身有问题。这时候把失败的文档 ID 单独拎出来,一篇一篇手工跑,通常能立刻定位。故障二:导出的 Markdown 在本地预览正常,上传到目标平台后格式全乱。这类问题多半是换行符和空行。Markdown 对空行极其敏感,列表前面少一个空行就变成普通段落,表格前面多一个空行就散架。我的做法是渲染完成后做一次规范化:统一换行符为\n,压缩连续三个以上的空行为一个,确保每个块级元素之间恰好一个空行。故障三:文档里的公式和特殊符号变成乱码。公式通常是以特殊格式的行内元素给出,直接取文本会拿到一堆转义字符。稳妥做法是原样保留公式标记,交给目标平台自己渲染;如果目标平台不支持,就存成图片或纯文本说明。特殊符号这块,写文件时显式encodingutf-8能解决绝大多数问题,但要注意 Windows 下默认编码的差异。故障四:解析速度突然变慢十倍。先怀疑是不是块索引那一步退化成线性查找了。另一个常见原因是日志打太多,每次请求都打完整响应体,IO 直接成瓶颈。我的经验是日志分级别:正常流程只打进度和耗时,响应体只在调试级别打,而且只打前 500 个字符。故障五:同一篇文档跑两次结果不一样。这个最让人抓狂。原因通常是块列表的顺序不稳定,或者渲染时用到了字典遍历顺序。解决办法是遍历前先按块 ID 或原始位置排序,保证顺序确定。这一条对增量同步尤其重要,否则指纹永远对不上。5.3 解析正确性校验光靠肉眼看几篇文档,不足以说明解析是对的。我一般会做三层校验:第一层是结构校验。统计原文的块数量和输出 Markdown 的元素数量,差异超过阈值就报警。比如原文有 120 个段落块,输出只有 90 个段落,说明有内容静默丢失了。第二层是文本校验。把所有块的行内文本拼成一个长字符串,去掉空白后和输出 Markdown 去标记后的文本做比对。这个能抓到“内容被吞”和“内容重复”两类问题,实现简单但很有效。第三层是回归样本。挑十篇有代表性的文档(含复杂表格、嵌套列表、代码块、图片),把它们的原始 JSON 存成 fixture,每次改解析代码就跑一遍,输出和基线做 diff。这是防止“改 A 坏 B”的唯一可靠手段。def verify(raw_blocks, markdown_text): raw_len sum(len(inline_to_text(b.get(elements, []))) for b in raw_blocks) md_len len(strip_markdown(markdown_text).replace(\n, ).replace( , )) ratio md_len / max(raw_len, 1) if not (0.95 ratio 1.15): raise ValueError(f文本长度异常: raw{raw_len}, md{md_len}, ratio{ratio:.2f})比率区间定在 0.95 到 1.15 之间是经验值。低于 0.95 说明丢了内容,高于 1.15 说明有重复或标记没去掉。这个简单的检查帮我抓出过好几次隐蔽的 bug。6. 实操心得与避坑清单写到最后,把那些文档里不会写、但真正影响项目成败的点整理一下。6.1 十条踩出来的经验先拿三篇文档做端到端,再谈批量。这三篇要有代表性:一篇纯文字带标题、一篇有复杂表格、一篇有图片和附件。三条都过了,后面的问题基本都是工程问题,不是解析问题。块类型一定要收集全。跑批量的时候,把所有遇到的块类型打到日志里统计一次。你会发现自己漏了至少五六种:折叠块、分栏、任务列表、时间线、思维导图。这些如果不处理,内容会静默消失。下载图片的目录要有清理策略。图片按哈希命名会越积越多,一年下来几十个 G。定期跑一个引用计数任务,把没有被任何 Markdown 引用的图片删掉。token 失效要能自愈,不能直接崩。长任务跑到一半 token 过期是很正常的,请求层应该捕获鉴权错误,刷新一次再重试。只刷新一次,别刷新无限次,否则密钥真的错了会死循环。不要在解析层做渲染。中间表示一定要和输出格式解耦。我第一次图省事直接边遍历边拼 Markdown 字符串,后来需要同时输出 HTML 和搜索索引用 JSON,只能整个重写。列表缩进用两个空格。Markdown 里嵌套列表的缩进,两个空格兼容性最好。四个空格在某些渲染器里会被识别成代码块,尤其是在有序列表里特别容易出问题。有序列表的编号要手动写吗?不用,全写1.就行,渲染器会自动编号。但如果原文是从某个数字开始的,那就得写实际数字,否则序号会从 1 重来。空文档要单独处理。有些文档刚创建还没来得及写内容,块列表是空的。这时候生成一个带标题的空 Markdown 文件就行,别在里面塞“本文档为空”之类的话,破坏一致性。进度日志要能看出“还剩多少”。只打“正在处理 xxx”没用,一出问题你根本不知道进度到哪了。打“第 137/1200 篇,已耗时 4 分 12 秒,预计还需 32 分钟”,这才是能用的日志。失败清单要能一键重跑。别把失败列表打印到屏幕上让人手动复制。直接输出成一个只含失败 ID 的文件,支持从这个文件读入重跑,这是运维友好的最低要求。6.2 一些实测数字和成本参考给几个量级参考,方便估算自己的方案。单篇普通文档(两三千字,无图)从请求到渲染完成,耗时大约在 0.3 到 0.8 秒之间,主要花在接口往返上。带十张图的文档,因为要下载,可能到 3 到 5 秒。一千篇文档,并发开到 10、限流设成每秒 10 次,整体跑完大概二十到三十分钟,具体看文档大小分布。存储方面,纯文本的 Markdown 一千篇大约一百到两百兆;如果包含图片且不去重,能到几个 G,按哈希去重后通常能压到三分之一以下。原始 JSON 如果全存,体积大概是 Markdown 的两到三倍,压缩后能降一个量级,适合放冷备。我个人在这类项目上的体会是:百分之七十的时间花在解析的边界情况上,百分之二十花在工程稳定性上,真正调接口的时间不到百分之十。所以一开始就别把精力放在“怎么发请求”上,那是最简单的部分。真正决定这个工具能不能长期用下去的,是它面对异常文档时是安静地吞掉内容,还是老老实实报错。宁可让脚本报错停下来,也不要让它静默生成一堆残缺的 Markdown——后者等你发现的时候,可能已经污染了整个知识库,清理成本高得离谱。