195 lines
7.7 KiB
Markdown
195 lines
7.7 KiB
Markdown
---
|
||
name: tencent-docs-mcp
|
||
description: >
|
||
通过腾讯文档 MCP 批量操作文档空间:迁移 Markdown 知识库到空间、建目录树、
|
||
批量建文档、验收内容完整性。当用户要求「把内容传到腾讯文档」「迁移知识库到腾讯文档」
|
||
「在腾讯文档建目录/文档」「同步文档到腾讯文档空间」时使用。
|
||
也适用于排查 tdoc_call 的 502 / no_token 报错。
|
||
agent_created: true
|
||
---
|
||
|
||
# 腾讯文档 MCP 批量操作
|
||
|
||
批量把 Markdown / 结构化内容写入腾讯文档空间,并做完整性验收。
|
||
|
||
## 一、🔴 最先要会排查的两个报错
|
||
|
||
### `ERROR:http_failed - Tunnel connection failed: 502 Bad Gateway`
|
||
|
||
**这个报错具有极强误导性** —— 看起来是网络/代理故障,实际 99% 是
|
||
**宿主下发的 API 域名不存在**。
|
||
|
||
排查三步:
|
||
```bash
|
||
# 1. 开调试,看 provider 下发的 api_base 是什么
|
||
TDOC_DEBUG=1 python tencentdocs.py tdoc_call tencent-docs <tool> '{}'
|
||
# → [TencentDocsSkillCredential] token provider result personal=available api_base=https://xxx
|
||
|
||
# 2. 验证该域名是否存在
|
||
nslookup <上面那个域名>
|
||
# → NXDOMAIN / Non-existent domain ⇒ 就是它的问题
|
||
|
||
# 3. 显式覆盖成正确域名
|
||
TDOC_API_BASE_URL=https://docs.qq.com python tencentdocs.py ...
|
||
```
|
||
|
||
个人版正确域名 = **`https://docs.qq.com`**。
|
||
⚠️ **不要**因为看到 502 就去 unset 代理 —— 那会变成 `getaddrinfo failed`
|
||
(本机往往无法直连外网,代理是必需的)。**问题在域名,不在代理。**
|
||
|
||
### `ERROR:no_token - 未检测到腾讯文档登录票据`
|
||
|
||
连接器没授权。让用户在连接器管理里授权「腾讯文档」即可。
|
||
`provider personal=not_connected enterprise=connector_disabled` 就是没连。
|
||
|
||
---
|
||
|
||
## 二、🔴 三个与文档描述不符的 API 行为
|
||
|
||
| 接口 | 文档说 | 实测 |
|
||
|---|---|---|
|
||
| `query_space_node` | 传 `parent_node_id` 查子节点 | **该参数完全被忽略**,总返回整个空间的扁平节点列表 |
|
||
| `delete_space_node` | `remove_type=all` 递归删除子节点 | **只删当前节点,子节点被提升到根目录** |
|
||
| `doc.create_with_markdown` | — | **不接收 space_id / parent_id**,只能建在个人根目录 |
|
||
|
||
**推论**:
|
||
- 无法用「查子节点」判断文件夹是否为空 → 删除前的空检查要靠**全量遍历**判断
|
||
- 删嵌套树不能一把梭 → 得逐个删,或删完再处理被提升的节点
|
||
- 建文档进空间必须两步:**先建、再移**
|
||
|
||
---
|
||
|
||
## 三、建文档进空间的正确路径
|
||
|
||
```python
|
||
# 1) 建文档 + 写内容(原子操作)
|
||
r = call("tencent-docs", "doc.create_with_markdown", {
|
||
"title": title,
|
||
"base64_markdown": base64.b64encode(md.encode()).decode(),
|
||
})
|
||
file_id = r["data"]["file_id"]
|
||
|
||
# 2) 移入目标文件夹
|
||
call("tencent-docs", "manage.move_file_to_space", {
|
||
"file_id": file_id, "space_id": SPACE, "target_parent_id": folder_id,
|
||
})
|
||
```
|
||
|
||
**为什么不用 `insert_markdown`**:那条路要先 `create_space_node(node_type=wiki_tdoc)`
|
||
建一个**空文档节点**,再写内容。中途失败就留下**空壳文档**,
|
||
而且两步之间用户点进去会看到空白页。`create_with_markdown` 是官方为一步建好设计的。
|
||
|
||
建文件夹:
|
||
```python
|
||
call("tencent-docs", "create_space_node", {
|
||
"space_id": SPACE, "title": name, "node_type": "wiki_folder",
|
||
"wiki_folder_node": {"title": name},
|
||
})
|
||
```
|
||
`node_type` 还支持 `wiki_tdoc`(在线文档)、`link`。
|
||
|
||
**耗时参考**:33 篇文档 ≈ **1 分 28 秒**。不必并发,串行足够。
|
||
|
||
---
|
||
|
||
## 四、🔴 验收判据:用「句子覆盖率」,别用片段指纹
|
||
|
||
这一步反复翻车过两次,方法论比代码重要。
|
||
|
||
### 为什么片段指纹不行
|
||
|
||
**坑 1:`get_content` 返回纯文本,表格结构信息全丢**
|
||
表格被拆成「单元格文本逐行拼接」,**没有 `|` 分隔符**。
|
||
拿源 md 的表格行(`预算明细表 | | | | |`)去比对必然失败。
|
||
|
||
**坑 2:三类格式差异会造成假的「内容缺失」**
|
||
| 差异 | 源 Markdown | 腾讯文档渲染后 |
|
||
|---|---|---|
|
||
| 列表编号 | `1. 必须带日期:…` | `必须带日期:…`(自动编号,**不返回前缀**)|
|
||
| 引号 | `"9月19日"`(直角) | `”9月19日”`(弯引号)|
|
||
| 链接 | `[文字](路径)` | 只剩`文字`,路径不显示 |
|
||
|
||
### 正确判据
|
||
|
||
```python
|
||
def norm(s):
|
||
"""激进归一化:只留实义字符"""
|
||
s = re.sub(r"[\s\u3000\u00a0]+", "", s)
|
||
s = re.sub(r"[|*`>#_~\[\]()()【】「」『』{}]", "", s)
|
||
s = re.sub(r"[\"'‘’“”‚‛„‟‹›«»]", "", s) # 各类引号(含弯引号!)
|
||
s = re.sub(r"[,,。.::;;!!??、-—–\-·…]", "", s)
|
||
s = re.sub(r"^\d+", "", s) # 列表编号残留
|
||
return s
|
||
|
||
def sentences(md):
|
||
body = re.sub(r"^---\n.*?\n---\n+", "", md, flags=re.S) # 去 frontmatter
|
||
body = re.sub(r"```[\s\S]*?```", "", body) # 去代码块
|
||
body = re.sub(r"^#{1,6}\s+", "", body, flags=re.M) # 标题标记
|
||
body = re.sub(r"^\s*\d+\.\s+", "", body, flags=re.M) # 有序列表编号
|
||
body = re.sub(r"^\s*[-*+]\s+", "", body, flags=re.M) # 无序列表符号
|
||
body = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", body) # 链接→只留文字
|
||
body = re.sub(r"\*\*|__|`|~~", "", body)
|
||
body = re.sub(r"^\s*\|", "", body, flags=re.M)
|
||
body = re.sub(r"\|\s*$", "", body, flags=re.M)
|
||
body = re.sub(r"\|\s*\|", " ", body)
|
||
parts = re.split(r"[。!?;\n]", body)
|
||
return [p.strip() for p in parts if len(norm(p)) >= 8] # 短句无判别力,丢掉
|
||
|
||
# 覆盖率 ≥ 90% 判通过
|
||
miss = [s for s in sentences(src) if norm(s) not in norm(got)]
|
||
cov = 1 - len(miss) / len(sentences(src))
|
||
```
|
||
|
||
实测结果:33/33 通过,多数 100%,最低 91%
|
||
(91% 那几篇差异来自表格跨行边界,**不是内容丢失**)。
|
||
|
||
---
|
||
|
||
## 五、工具清单(224 个,按需取用)
|
||
|
||
```bash
|
||
python tencentdocs.py tdoc_list tencent-docs # 列全部工具
|
||
python tencentdocs.py tdoc_schema tencent-docs <tool> # 查参数(★ 调用前必做)
|
||
python tencentdocs.py tdoc_init # 检查 token
|
||
```
|
||
|
||
`--no-proxy` 可绕过代理(但通常不需要,问题在域名);`--timeout` 调超时。
|
||
|
||
常用分组:
|
||
- `manage.*` — 创建/移动/搜索/权限/导入导出(`create_file` `move_file_to_space` `folder_list` `search_file`)
|
||
- `doc.*` — Word 文档读写(`create_with_markdown` ★ `insert_markdown` `insert_table` `find_and_replace`)
|
||
- `create_space_node` / `query_space_node` / `query_space_list` / `delete_space_node` — 空间树
|
||
- `sheet.*` / `smartsheet.*` / `slide_*` / `flowchart.*` — 表格/智能表/幻灯片/流程图
|
||
- `get_content` — 读文档纯文本(验收必备)
|
||
|
||
---
|
||
|
||
## 六、幂等与可续跑
|
||
|
||
批量任务一定要能断点续跑 —— 33 次调用中途断掉不能从头来。
|
||
|
||
```python
|
||
STATE = Path("_migrate_state.json") # {"folders": {名: id}, "docs": {key: {file_id, ...}}}
|
||
if stem in st["docs"]:
|
||
print("跳过(已完成)")
|
||
continue
|
||
# ... 完成后立刻写回
|
||
st["docs"][stem] = {"file_id": fid, "title": title, "section": sec}
|
||
save_state(st) # 每篇写完就落盘,不要等全部结束
|
||
```
|
||
|
||
文件夹同样做「已存在则复用」:
|
||
先查根目录同名节点,有就复用,没有才建 —— 避免重跑产生一堆重复文件夹。
|
||
⚠️ 因为 `parent_node_id` 不生效,只能靠**全量列表 + 标题匹配**来找。
|
||
|
||
---
|
||
|
||
## 七、危险操作
|
||
|
||
`delete_space_node` 不可逆。执行前必须:
|
||
1. 用**全量遍历**确认该节点子树内 **0 篇文档**(因为查不了子节点)
|
||
2. 把待删结构 dump 到文件留档
|
||
3. 向用户确认
|
||
|
||
⚠️ `remove_type=all` 不是递归删除(见第二节),别指望一把删干净。
|