腾讯文档 → 钉钉知识库 高保真同步
为什么不能直接用 get_content
腾讯文档 MCP 的 get_content 只返回纯文本投影,实测会丢三类东西:
| 内容 | get_content 结果 |
|---|---|
| 代码块 | 完全丢失。代码块在腾讯侧是浮动 TextBox,不占正文文本流。例:请求成功返回: 后面直接跳到下一节,中间的 JSON 样例整段没有 |
| 图片 | 拿不到,需另外调 doc.get_images 拿 URL + pos |
| 标题层级 / 表格结构 | 拿不到,需 doc.resolve_document_structure(但也读不到代码块正文,include_textbox_children 也展开不了,只返回 [TextBox]) |
结论:只要原文档可能有代码块或图片,就必须走「导出 → 还原」路线,不能用 get_content 打草稿。
环境准备
前置:本机装好 WorkBuddy,并在连接器里授权「腾讯文档」和「钉钉」。
脚本不写死本机路径,按「环境变量 > 常见安装位置」自动探测,找不到会打印该设哪个环境变量:
| 依赖 | 环境变量(优先级最高) | 自动探测位置 |
|---|---|---|
| 腾讯文档 CLI 用的 python | TD_PY |
~/.workbuddy/binaries/python/versions/*/python.exe,最后退回当前解释器 |
| 腾讯文档 CLI 本体 | TDOCS |
~/.workbuddy/plugins/cache/*/tencent-docs-plugin/*/skills/tencent-docs/tencentdocs.py |
| 钉钉 dws CLI | DWS |
① %APPDATA%\qianfan-desktop-app\lightsandbox\node\dws.cmd(Windows,目前实测有效)② ~/.workbuddy/binaries/node/cli-connector-packages/node_modules/dingtalk-workspace-cli/bin/dws.js |
跑 dws.js 的 node |
DWS_NODE |
~/.workbuddy/binaries/node/versions/*/node.exe → PATH 里的 node |
> dws 的安装位置随 WorkBuddy 版本变过(早期在 .workbuddy/binaries/node/.../dws.js,后来挪到 qianfan 的 dws.cmd)。
> 两个候选都保留了;都不中就直接设 DWS,别去改脚本。
手动验证两条链路是否通:
"$DWS" wiki node list --workspace <知识库ID> --format json # 钉钉
"$TD_PY" "$TDOCS" tdoc_call tencent-docs manage.folder_list '{"folder_id":"<FOLDER_ID>"}' # 腾讯
Windows 下的两个老坑(与本机相关,换机器不一定复现):
- Bash 里
head/tail/ls/sleep可能不存在(Git Bash shim 损坏),会把整条命令连同 python 一起带崩(exit 127)。用 PowerShell 7 更稳。 - PowerShell 的 stdout 偶尔抓不全 → 让脚本把结果写文件,再读文件。
三类型分流
manage.folder_list 返回的 url 可判类型:
docs.qq.com/doc/*→ 文字文档 → docx 还原路线docs.qq.com/sheet/*→ 在线表格 → 导出后dws doc import(钉钉在线表格)docs.qq.com/flowchart/*→ 流程图 →flowchart.read+ 自渲染 SVG(腾讯导出会报错导出processOn文件失败,此路不通)
路线 A:文字文档(docx → JSONML,默认)
> 为什么默认走 JSONML 而不是 Markdown:Markdown 表达不了合并单元格,代码块也只是围栏文本
> (早期方案里钉钉甚至把代码块转成 SVG 图片,看得见复制不了)。
> JSONML 能写出真正的合并单元格、真正的 code 块和真正的标题层级。
> 只有需要纯文本草稿时才用 --format md。
1. 导出(腾讯)
manage.export_file {"file_id": ...} → task_id
manage.export_progress {"task_id": ...} → 每 3s 轮询,progress==100 拿 file_url
典型耗时 3~6s
GET file_url → 存到系统 temp 目录(不要存用户工作区)
2. 图片 URL
doc.get_images {"file_id":...} → [{image_url, pos, source}],按 pos 升序排好后,与 docx 里 drawing 的出现顺序一一对应。
数量常有 1~2 张的差额(封面/重复),对齐消耗用不完属正常。
3. 解析 docx 的关键点
- 代码块 =
<w:txbxContent>,其下每个<w:p>是一行 → 拼成围栏代码块。
语言标签可按内容猜(以{/[开头且带引号 →json;using System→csharp;<开头 →xml…),猜不出留空。 - 标题层级:腾讯导出的
pStyle/@w:val是随机串(如z2xbei),真实名称在word/styles.xml的<w:style w:styleId=...><w:name w:val="heading 1"/>。必须先建 styleId→name 映射再判层级。
Title不转成#(钉钉文档标题已经是它),heading N→#*N。 - 表格:
<w:tbl>→["table", ...]。合并单元格必须按下面的写法,否则服务端静默忽略: - 纵向合并
w:vMerge(restart/continue):起始格算rowSpan(向下数连续的continue),
被覆盖的位置要输出["tc", {"colSpan":1,"rowSpan":1,"hidden":true}, ["p",…]]——hidden:true是必须的。 - 横向合并
w:gridSpan val=N:起始格colSpan=N,后面N-1格不输出。 colSpan/rowSpan即使等于 1 也显式写出,与钉钉 canonical 形态一致。- 物理网格必须完整:每行输出的
tc数要等于列数(被覆盖处用 hidden 占位补齐)。 - 列宽别均分:读
<w:tblGrid>/<w:gridCol w:w>,按比例缩放到总和 650pt 写进colsWidth
(钉钉只把它当比例用),否则宽表会被拉成等宽,观感和原文差很远。 - 回读校验:
dws doc +fetch --node <id> --detail full拿 JSONML,续行应是hidden占位,数字对得上。 - 超链接:腾讯 docx 里几乎没有
<w:hyperlink r:id>,链接是字段指令:
<w:fldChar begin>→<w:instrText>HYPERLINK https://xxx \tdfe ...</w:instrText>→separate→<w:t>显示文字</w:t>→end。
只取<w:t>会只剩文字、丢掉 URL。解析要点:HYPERLINK后的 URL 常常不带引号,正则要同时支持HYPERLINK "url"和HYPERLINK url。
需要把腾讯链接改写成钉钉链接时,用dws drive info取dentryId拼 transit 链接:
https://alidocs.dingtalk.com/api/doc/transit?spaceId=<spaceId>&dentryId=<dentryId>&corpId=<corpId>
(这就是钉钉 UI 里复制文档链接得到的形式;腾讯 URL 的 slug 与folder_list返回的 url 末段一一对应))。 - 图片:顶层
<w:p>内<a:blip>个数 = 该段图片数,按序从 URL 队列取值,写。 - 顺手清理
HYPERLINK xxx normalLink \tdfe -10 ...这类导出残留。
3b. 自动编号(numbering.xml)与钉钉原生自动编号
源文档的编号不在文本里。 Word/腾讯的多级编号是段落上的 <w:numPr>(ilvl, numId)
引用 word/numbering.xml 的定义(numFmt 编号格式、lvlText 如 %1. / %1.%2、
start 起始值),由排版引擎算出 "1." / "2.1" / "一、" 再显示。只取 <w:t> 会整段丢编号
(典型反馈:"自动编号也没了")。
重建要点(见 build_numbering / Numbering / para_num_info / fmt_num):
w:num把 numId 映射到 abstractNumId;w:lvl按ilvl给出各级numFmt/lvlText/start。- 计数按 numId(列表实例)分开;出现更浅层级时,更深层归零。
numFmt要支持decimal/lowerLetter/lowerRoman/chineseCountingThousand
(一、二、…十、十一,中文数字得自己转)。- 空
<w:numPr/>= 没有编号;numId=0= 显式取消编号。 - 腾讯导出的 docx 常不写
w:suff,实测渲染为「编号紧贴文字」,所以缺省不加空格。
写到钉钉有两种做法,默认走原生自动编号:
| 做法 | 写法 | 何时用 |
|---|---|---|
| 原生自动编号(默认) | 给 h1~h6 挂 attrs.list,编号由钉钉生成 |
有编号的标题(推荐) |
| 固定编号 | 把算出的编号写进标题文本 | --no-heading-auto-number,或 Markdown 载体(MD 表达不了 list) |
原生自动编号的规范(照抄自「在钉钉 UI 里手动加一次自动编号 → 回读 JSONML」):
["h1", {
"ind": {"hanging": 0, "left": 0},
"list": {
"listId": "heading-xxxxxxxxxxxx",
"level": 0,
"isOrdered": true, "isTaskList": false, "autoLevel": true,
"listStyleType": "DEC_DEC_DEC_P",
"listStyle": {"format": "decimal", "text": "%1", "align": "left"},
"symbolStyle": {"bold": true, "sz": 21}
}
}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "标题"]]]
listId形如heading-前缀,同一编号序列的标题共享;level为 0 起。listStyle.text按层级递增:%1/%1.%2/%1.%2.%3(四级即%1.%2.%3.%4),
配合DEC_DEC_DEC_P渲染成 1 / 1.1 / 1.1.1。level必须按「标题级别」算(h1→0、h2→1),不能用源ilvl—— 源文档各级标题
可能同属ilvl=0(各自是独立列表),照搬会渲染成1. 2. 3.而不是1 / 1.1。- 标题上手打的编号(
1.2.1、一、、第一章)要先剥掉再挂 list,否则重复编号。
中文序号(一、/第一章)也要识别,不能只认阿拉伯数字。 - 编号样式若钉钉预设没有,就统一用
1 / 1.1 / 1.1.1(DEC_DEC_DEC_P)。 text不带尾点(%1不是%1.)——尾点由listStyleType的_P决定。symbolStyle不要抄(踩过,2026-09-17):上面样例里的"symbolStyle": {"bold": true, "sz": 21}
是 h1 单级的取值,原样复制给所有层级会把各级标题字号拉平(h2 和 h3 一样大,用户一眼看出)。
默认不写symbolStyle,钉钉会按标题级别用各自默认字号;真要写就按层级给不同sz。- 自动编号的数字字形是钉钉内置字,改不了字体族(踩过,2026-09-17):
symbolStyle里只有bold/sz/szUnit对
编号数字字号和字重生效;fonts: {ascii, eastAsia, ...}写在symbolStyle里会被静默忽略,编号数字始终用
钉钉列表默认字(PDF 导出里看到的是ArialMT/Arial-BoldMT)。所以「编号数字」和「中文标题」字体族天然不一致
(如标题是 PingFangSC-Semibold、编号是 Arial),只能做到字号一致 + 字重一致(加bold:true),
字体族无法统一。若要编号与标题完全同字族,只能退回「手写编号文本」(牺牲自动重排)。— WorkBuddy国内版
缩进:ind 是段落属性,不能塞进 list(踩过)
"ind": {"hanging": 0, "left": 0} 必须和 list 平级放在 node[1] 里。
写进 list 内部服务端静默忽略,标题就按 list level 被逐层缩进——
h2/h3 会一节节往右跑。二/三级标题天然不该缩进,顶格即可。
node[1]["list"] = heading_list_attrs(lvl, list_id)
node[1]["ind"] = {"hanging": 0, "left": 0} # 平级!不是 list["ind"]
正文有序列表:只写本级占位符,不是全路径
正文和标题的 listStyle.text 规则不一样,混用会取不到计数器:
listStyleType |
level 0 / 1 / 2 的 text |
渲染 | |
|---|---|---|---|
| 标题 | DEC_DEC_DEC_P |
%1 / %1.%2 / %1.%2.%3 |
1 / 1.1 / 1.1.1 |
| 正文 | DEC_LEN_LROM_P |
%1. / %2. / %3. |
1. / a. / i. |
- 正文每级只写本级占位符(
level:1→"%2."),不是"%1.%2";
正文text带尾点,标题不带。 - 正文格式按 level 三级一轮:
decimal→lowerLetter→lowerRoman→decimal… - 正文
listId不带heading-前缀,按源 numId 分组(不同列表各自从 1 数)。 -
剥编号后要判空:
text.strip()为空就别挂list,否则出空节点。 -
正文统一用
1. / a. / i.这一种样式,不要按源numFmt去猜、也不要照抄参考文档里的
正文样式——实测照搬/猜都容易错。标题仍是1 / 1.1 / 1.1.1。
参考实现:scripts/sync_docs.py 的 heading_list_attrs / body_list_attrs。
4. 写入钉钉(JSONML)
# 新建
dws doc create --name "标题" --workspace <WS> --content-file body.json --content-format jsonml
# 覆盖已有(同名不新建)
dws doc update --node <nodeId> --content-file body.json \
--content-format jsonml --mode overwrite --yes
- 根节点必须是
["root", {}, ...blocks],否则报 root 校验错误。 - 内容用
--content-file传;--content -(stdin)实测会传不进去,+create --content @file
又只接受工作目录内的相对路径。 - 常用节点:
h1~h6标题、p段落、code {code, syntax}代码块(可复制,不是图片)、
table/tr/tc(colSpan/rowSpan)、img {src}(必须包在p里)、a {href}。 a节点不接受裸字符串,子节点要写成
["a", {"href": url}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "文字"]]],
否则 validator 报bare text not allowed in "a"。- 图片 URL 会被钉钉抓取并内联到自己的 OSS(回读变成
alidocs2.oss-cn-zhangjiakou.aliyuncs.com),不用手动上传。 - 取同名节点清单:
dws wiki node list --workspace <WS>(dws doc list已废弃)。
必须递归子文件夹(--folder <nodeId>),见坑清单。
4b. 写入钉钉(Markdown,备选)
- 已有同名节点 →
dws doc update --node <id> --content-file x.md --mode overwrite --yes - 没有 →
dws doc create --name <标题> --workspace <WS> --content-file x.md - 合并单元格只能二选一:
--merge-mode blank(续行留空,标准做法,默认)或fill(续行重复填值)。
5. 大文档必须分段
单篇超过约 3 万字符时,overwrite 会分片写入并报 TIMEOUT_ERR(可重试但常失败)。
可靠做法:12000 字符一段,第 1 段 overwrite,后续段 append。
成功判定要写成 '"success":true' in out.replace(" ", "").replace("\n", "") —— out 里混有 [INFO] 前缀行,且 JSON 是带空格的,写 '"success": true' 会永远判成失败。
路线 B:在线表格
导出得到 .xlsx → dws doc import --file <tmp.xlsx> --workspace <WS> --name <标题> -y → 钉钉在线表格,格式保真。
若知识库里已有同名旧文档(比如早期用纯文本方案建的空壳),先 dws drive delete --node <dentryUuid> -y 移到回收站(30 天可恢复),再导入,避免出现 XXX(1) 副本。
路线 C:流程图
腾讯侧导出必失败,改用:
flowchart.read {"file_id":...}→mx_cells_xml(裸 mxCell)- 自己解析:
vertex=1取 x/y/width/height +value(标签,内含<div>/<br>,需清洗);edge=1取 source/target - 渲染成 SVG(矩形 + 中心文字 + 带箭头的连线),末尾叠加一张图
dws doc create建一篇文档写「节点清单 + 连线关系」→dws doc media insert --node <id> --file x.svg -y插图
参考实现:scripts/sync_docs.py --kind flowchart 的用法见文件末尾。
坑清单
| 现象 | 原因 / 解法 |
|---|---|
| 代码块变成看得到复制不了的图 | 走了 doc import --file xx.docx:钉钉把 TextBox 代码块转成了 data:image/svg+xml;base64。必须走 docx→Markdown 还原 |
doc update 报「第 1 片写入失败 … TIMEOUT_ERR」 |
内容太长,改分段 overwrite+append |
| 脚本里明明成功却判失败 | 成功判定字符串没去空格,见上文 |
drive delete 报 nodeId 格式不合法…32 位 |
要传 32 位 dentryUuid。drive info 返回的 dentryId 是 12 位纯数字(如 236599452310),传它必然报错;直接用 nodeId(32 位)即可,或用 dws drive list 的 fileId。另:wiki node list 偶发给 31 位 nodeId 也要换 |
resolve_document_structure 的 mode 不生效 |
传 mode:2 仍返回 effective_mode=compact;要 full 得传 has_idx:true + idx |
Git Bash 报 command not found: ls/head/tail/sleep |
shim 损坏,见「环境准备」 |
| 标题全变成普通段落 | 没做 styles.xml 的 styleId→name 映射 |
| 表格里的字"不见了" | 纵向合并 vMerge:continue 是空单元格,要向下填充上方 restart 的内容 |
| 表格后面的列整体错位 | 横向合并 gridSpan 没展开补空,各行实际列数不一致 |
| 链接只剩文字没有 URL | 超链接是 fldChar/instrText 字段,不是 <w:hyperlink>;且 HYPERLINK 后 URL 常不带引号 |
rowSpan/colSpan 写了却没合并 |
省略了被覆盖位置的 tc。网格必须完整,续行用 hidden 占位补齐,见「解析 docx 的关键点」 |
| 合并单元格行高被撑到整页、没合并的行也跟着变形/错位 | 续行占位只写了空 tc、漏了 "hidden": true。钉钉把空 tc 当真单元格 → 该行列位右移、行高按整页撑开。必须 {"colSpan":1,"rowSpan":1,"hidden":true} |
| 表格每列被拉成等宽 | colsWidth 一律均分。要按源 docx tblGrid 比例缩放 |
同名文档被重复新建成 XXX(1) |
node_map 只扫了根目录,文档其实在子文件夹里 → 必须递归 --folder |
| 链接改写全部落空 | 同上(node_map 不递归)+ 单篇同步时没传 --folder,映射表是空的 |
bare text not allowed in "a" |
JSONML 里 a 的子节点不能是裸字符串,要包 span > leaf |
| JSONML 写入报「回读结果与完整初始内容不一致」 | 用了 --content -(stdin)。改用 --content-file |
表格块回读只有 cells/colSize/rowSize |
正常:CLI 投影不暴露合并信息,判据是续行被覆盖处为空串 |
想生成 transit 链接却拿不到数值 spaceId |
CLI 给不出(drive info 只有 dentryId,wiki space get 只有 workspaceId)。--space-id/--corp-id 不传时脚本自动退回 https://alidocs.dingtalk.com/i/nodes/<nodeId>,可用。要严格 transit 形式就让用户在 UI 里复制一条链接,从里面读出 spaceId |
整轮同步崩在 node_map:'NoneType' object has no attribute 'get' |
wiki node list 返回的 nodes 数组里混着 null(不是空对象)。遍历要 isinstance(n, dict) 过滤 |
--exclude 传 file_id 完全没生效 |
--exclude 只匹配标题;要按 id 精确挑文档改用 --only(它同时认 id 和标题) |
PowerShell 传中文参数匹配不到(ITEMS 0) |
命令行中文被编码弄坏。挑文档一律用 ASCII 的 file_id,别用标题 |
JSONML 写完报 business error: success=false / HSF 超时 |
单篇太大。脚本会自动剥 uuid + 重试(JSONML_BIG 默认 12 万字符触发);再失败才退回 Markdown。退回后编号是写死在文本里的(MD 表达不了原生编号),超大文档的已知代价 |
导出报 drivecgi Service Error, code:11607 / no_task_id:{} |
腾讯服务端对该文档的导出接口是坏的,CLI / MCP 两条通道、docx/pdf/txt/md/html 全部格式都返回同一个错,重试无效。降级方案:改用 get_content 取正文(代码块会被平铺到末尾且丢失归属,要按语义手工归位),然后手工拼 JSONML 写入 —— 代码块用真 ["code", {"syntax": ..., "code": ...}] 节点,标题照常挂 heading_list_attrs |
| 粘贴的腾讯链接 slug 解不开(base64 非法) | 链接被截断/大小写错。腾讯 slug 去掉首字母后是 file_id 的 urlsafe base64;解不开就穷举单字符修正(改 1 个字符 → base64 decode → query_file_info 验证),十几到几十次调用即可命中 |
doc block insert 报「JSON 语法错误: unexpected end of JSON input」 |
走了 dws.cmd。cmd.exe 会截断含中文的长 JSON 参数。改成直接 node <dws.js> ...(dws.js 在 ~/.workbuddy/binaries/node/cli-connector-packages/node_modules/dingtalk-workspace-cli/bin/dws.js),绕开 cmd.exe 后 60/60 全成功 |
逐块 insert 越跑失败越多(business-level error),且失败的都是小块 |
服务端限流,不是体积问题(32KB 的大表格反而成功)。要降频(块间 sleep ~0.4s)+ 指数退避(5/15/45s) |
doc block insert 传数组想一次插多块 → Invalid JSONML node: element array |
一次只能插一个块。多块要循环调用 |
精简 JSONML 想省体积:["p", {}, "文本"] |
服务端(CLI)会自动 wrap raw string into span/text/leaf 修回去,等于白省。能省的只有 colSpan/rowSpan 等于 1 的字段(不会被补回)。别在 p 里塞裸字符串 |
| 用户给的钉钉链接当成目标文档,结果文档被塞到别处 / 报错 | 先用 dws drive info --node <id> 看 nodeType:folder 就当 --parent(文档建进该文件夹),file 才是对它做覆盖写。别想当然 |
回读 block list --content-format jsonml 看到 46 块全是 p,图片/编号一个都没有 |
回读是简化投影,图片和有序列表都不体现在 blockType 里。想确认图片和编号真的写进去了,用 dws drive export --node <id> --export-format markdown 导出后统计 ![ 条数和有序列表行数 |
drive export 明明成功却拿到 exit code 1 |
[INFO] 进度行走 stderr,PowerShell 把它判成 NativeCommandError。看输出里的 "success": true + 目标文件是否存在,别信 exit code |
标题的手工编号是字母形式(a: / b. / h: xxx)时没挂原生编号,字母前缀还留着 |
MANUAL_NUM_ANY_RE 只认数字和中文数字(1.2.3 / 1、 / 一、 / 第一章),不认字母编号 → 这类标题 numbered=False,既没有原生编号、前缀也没剥。产后修法:回读找出无 list 的 h 块,doc block update 挂 heading_list_attrs(层级-1, 文档里已有的 listId) + ind:{left:0,hanging:0},同时剥掉 ^[a-zA-Z]\s*[::.、]\s* |
| 修完一轮只修了一部分就说「没有更多目标」 | block update 会重建块并换 blockId,导致后续分页漂移;而且分页请求偶发失败返回空(token 抖动)时若直接 break,会漏掉文档后半部分的块。必须:① 每修一个重新 block list;② 整页失败要重试整轮,不能 break 后拿残缺列表判断"修完了" |
取块 ID 拿到 None |
block list --content-format jsonml 返回的块字段是顶层 blockId,不是 element.id(默认格式下才是 element.id) |
sheet/other 全部 NEED_MANUAL ext=None,但文件明明下载到了 |
导出物被存成了 .bin,扩展名检测必然落空。现在 export_file() 会从 Content-Disposition / URL 推断真实扩展名(sheet→.xlsx)并拼到文件名上 |
OK_IMPORT 报成功,但钉钉里根本没这篇 |
dws doc import 的 --folder 与 --workspace 互斥,同时传报 if any flags in the group [folder workspace] are set none of the others can be。而且这个错误只在 stderr/返回码里,stdout 是空的 → 脚本原来只看 nodeId 就会误判成功。现在已改成显式判失败并列 ERR_IMPORT。用法:有 --parent 就只传 --folder,否则才传 --workspace |
dws drive list --node <文件夹id> 返回 items: [],但文档明明在里面 |
列知识库子目录必须带 --workspace:dws drive list --workspace <WS> --folder <folderId>。只给 --node 会被路由到钉盘,返回空 |
--file 直通时某篇被判成 other、标题还变成 file_id |
manage.query_file_info 偶发返回空(token 抖动),type/title 拿不到。已加重试 3 次。误判成 other 会让 doc 走 import 路线 → 代码块变图片 |
超大文档(超过 JSONML 单次上限)怎么办
JSONML 单次 overwrite 有体积上限(实测 20 万字符 OK / 30 万字符失败),且服务端明确不支持 append
(原文:--content-format jsonml 当前仅支持 --mode overwrite,append 模式将在后续版本支持)。
❌ 不推荐:首写 + 逐块 block insert
理论上可行(doc block insert --content-format jsonml --element <单块> --index 999999,
标题 list / 表格合并 / 代码块都能保留),但实测在千块规模会崩:
1102 块的文档跑到 800+ 块时服务端限流加剧,出现连续失败(880/882/885/886/887/889…),
耗时 1h47m 仍失败 20+ 块,且失败块只能补到末尾造成错位。别在这条路上耗时间。
✅ 推荐:Markdown 回退 + 只给标题补原生编号
退回 Markdown 能保证内容 100% 完整(实测 1102 块 / 67 表格 / 298 图一个不少,
和源 JSONML 块数完全一致),代价是标题编号被写死进文本。补两步即可:
- 挂 list:对每个标题块
doc block update --block-id <id> --content-format jsonml --element <新块>,
在attrs里加list: heading_list_attrs(层级-1, 同一个listId)。305 个标题约 15 分钟,0 失败。 - 剥号:同一个 update 里把
["span",{"data-type":"leaf"},"1.2.3 标题"]的文本换成
strip_manual_number()剥掉前缀后的结果。否则会双重编号(1. 1.2.3 标题)。
三个坑(都踩过):
- 必须同时写段落级 ind: {"hanging":0,"left":0},和 list 平级放在 node[1]。
只挂 list 不加 ind,标题会按 list level 被逐层缩进(h2/h3 往右跑),
「标题不缩进、只有正文多级编号才缩进」这条就不成立。
heading_list_attrs() 只返回 list,ind 要调用方自己加(HEADING_IND)。
- block update 会重建块并返回新的 blockId。预先缓存的 id 会失效,
报错是空的 NOJSON。必须用最新一次 block list 的结果,别拿旧快照。
- 取 JSONML 里的文本要匹配 ["span",{...},["span",{...},"文本"]] 结构。
别写"遇到第一个 str 就返回"的递归——那会返回 tag 字符串 "span",导致剥号逻辑静默失效(stripped=0)。
改完必须用 block list 回读确认四项:headings / auto_numbered / ind_left0 / baked。
参考产出:《低代码框架功能说明V2》1102 块 / 305 标题,四项全绿。
⚠️ 探测体积上限会留下测试文档,用完必须删
为了确认「20 万字符 OK / 30 万字符失败」,会在知识库根目录建若干测试文档
(实测用过 SLIM_FULL_TEST / SLIM_FULL_TEST(1) / BIG_A_FULL / BIG_B_SLIM / LIM_300K
这类名字,一对一对应 %TEMP% 下的同名 .json)。这些是垃圾文档,会污染目录、
让用户在目录面板里看到莫名其妙的条目。规矩:
- 探测完立刻删,别等用户发现;
- 删除目标必须是测试文档,不要碰正式文档;
- 删除前先
dws drive +search-docs --query <名字>列出候选、确认身份,
再dws drive delete --node <32位nodeId> -y(进回收站,30 天可恢复); - 删完再用
+search-docs复核count: 0; - 本地
%TEMP%的同名.json一并删掉。
批量确认身份 + 删除的现成写法:
# 1) 搜索(只读)
node "$DWS" drive +search-docs --query LIM_300K --format json # 给 nodeId
# 2) 元数据(拿 createTime / workspaceId / 父目录,核对确实是测试产物)
node "$DWS" drive info --node <nodeId> --format json
# 3) 删除(nodeId 必须是 32 位)
node "$DWS" drive delete --node <nodeId> -y --format json
# 4) 复核
node "$DWS" drive +search-docs --query LIM_300K --format json # 期望 count: 0
常用命令
脚本:scripts/sync_docs.py(绝对路径见「环境准备」,用 3.13 的 python 跑)。
# 全量同步一个文件夹(默认 JSONML)
python sync_docs.py --folder <FOLDER_ID> --workspace <WS> \
--kinds doc --format jsonml --exclude "要跳过的子文件夹" \
--rewrite-links --space-id <spaceId> --corp-id <corpId>
# 同步进知识库里的某个子文件夹(不是根目录)
python sync_docs.py --folder <FOLDER_ID> --workspace <WS> \
--parent <钉钉文件夹nodeId> --kinds doc --format jsonml
# 只同步一篇 / 几篇(--file 直通 file_id,不依赖文件夹)
python sync_docs.py --file <FILE_ID> --workspace <WS> --format jsonml
python sync_docs.py --file <FILE_ID1>,<FILE_ID2> --workspace <WS> --format jsonml
# 单篇同步但链接映射仍要全量:--folder 与 --file 一起传(folder 只用于建映射,不处理)
python sync_docs.py --folder <FOLDER_ID> --file <FILE_ID> --workspace <WS> --rewrite-links ...
# 只枚举看看有什么,不写入
python sync_docs.py --folder <FOLDER_ID> --workspace <WS> --list
# 单篇同步到知识库里的某个子文件夹(--file + --parent 组合,不依赖腾讯文件夹)
python sync_docs.py --file <FILE_ID> --workspace <WS> --parent <钉钉文件夹nodeId> --format jsonml
# 回读校验(图片/编号):block list 是简化投影,要用导出确认
DWS=<dws.js 路径>
node "$DWS" drive export --node <钉钉nodeId> --export-format markdown --output /tmp/x.md
# 然后统计 ![ 条数(图片)、^\s*\d+\.\s 行数(有序列表)
| 参数 | 说明 |
|---|---|
--file |
逗号分隔 file_id,支持 <id> 或 <id>:<自定义标题>;与 --folder 二选一 |
--only |
逗号分隔关键词,在已枚举出的条目里再筛 file_id / 标题 |
--kinds |
只处理这些类型,如 --kinds doc(表格/流程图会被跳过) |
--parent |
写入指定钉钉文件夹的 nodeId(不传则建在知识库根目录)。传了之后查重范围也收窄到该文件夹内,避免把别处的同名文档误判成「已存在」而更新错对象。取法:dws wiki node list --workspace <WS> 里 nodeType=folder 的 nodeId |
--update-only |
只更新知识库里已存在的同名文档,不新建。用户手动删掉的文档不要再同步回来 |
--format |
jsonml(默认,真合并+真代码块)/ md(纯文本草稿) |
--merge-mode |
仅 md 生效:blank(续行留空,默认)/ fill(续行重复填值) |
--rewrite-links |
把正文里的腾讯链接改写成钉钉链接;配 --space-id / --corp-id 生成 transit 形式 |
怎么验证渲染结果(行高 / 合并 / 列宽)
回读 JSONML 只能证明结构对,视觉对不对必须看渲染。两个可复用的手段:
- 导出 PDF 看:
dws doc export --node <id> --export-format pdf --output <tmp>.pdf -y,
再用 pymupdf 渲染成 PNG 直接看(沙箱里没装就python -m venv后pip install pymupdf)。
想量行高就把 PDF 里的水平线 y 坐标抽出来,相邻两条线之差就是行高;
判合并看某条横线是否只在部分列出现(某一列不出现 = 那一列被纵向合并覆盖)。 - 让 UI 当老师:想知道钉钉自己怎么表达某个结构,让用户在 UI 里手动做一遍,
然后dws doc +version-list --node <id>找那一版,dws doc +fetch --node <id> --version N把 JSONML 拉下来照抄。
(本次hidden:true就是这么定位的:手动合并那一版里,续行是{"colSpan":1,"rowSpan":1,"hidden":true}。) - 小样对照实验:建一篇临时文档,塞几张结构相同、写法不同的小表,一次导出 PDF 就能对比出哪种写法对。
注意colsWidth的长度会被当成列数——长度和实际列数不一致时服务端会补出幽灵列,干扰判断。
收尾
同步完务必回读校验(字数 / 代码块数 / 图片数 / 标题数),并清掉测试节点与 (1)、(2) 重复件。