1. 为什么要迁Confluence 维护成本与 sward 的轻量定位上周刚把一个 200 多页的 Confluence 空间迁到 sward整个导入过程跑了三轮才彻底干净。如果你也在纠结“如何把 Confluence 数据导入 sward”这篇文章应该能帮你省掉至少一周的排查时间。先摆一个观点Confluence 不是不能用的知识库恰恰相反它太“完整”了。页面权限、空间结构、宏面板、公告横幅、通知流、Jira 联动这些能力对大团队很有价值但对只想好好写文档和沉淀经验的团队来说大部分功能一年都用不上一次。可你依然要为这些特性付出代价比如页面加载明显偏慢、编辑器偶尔卡顿、许可证成本按人头算还有每次组织架构调整都要重配一遍权限组。时间一长团队实际使用的页面可能只剩几十个其余全是若干年前的历史存档这种情况下继续维护 Confluence 感觉就像开着房车去取快递——不是不行是没必要。sward 这类轻量知识库正好补上这个空档Markdown 原生编辑空间和页面两级结构清晰API 开放页面之间的关系用普通链接就能表达。再加上渲染快、界面干净非常适合技术团队做内部文档中心、项目常识库或者运营手册。但轻量不意味着迁移时可以直接把数据倒过去Confluence 的导出文件并不是一套现成的 Markdown 工程里面有 XML 实体、宏标签、附件相对路径和站内链接需要经过“格式清洗—结构重建—链接修复—附件对账”四步才能干净落地。这也是本文真正想讲清楚的东西。很多人在迁移前最容易犯的错是把“数据导入”理解成“文件复制”。实际上一次成功的知识库迁移核心是保住三样东西页面之间的父子层级、正文里的图片和附件、站内互链关系。这三样只要丢掉一样迁移后的文档就会变成一堆互相找不到入口的孤岛团队到最后还是会回去翻旧系统。所以下面的步骤不是按“能导入就行”的思路写而是按“导入完能直接日常使用”的标准来设计。2. 从Confluence端准备导出不只是点一下 Export2.1 导出格式怎么选XML 包比 HTML 包更适合后续处理Confluence 在 Space Tools 里提供两种常见的整空间导出方式导出为 HTML 和导出为 XML。很多人图省事直接选了 HTML因为解压后能用浏览器直接打开看感觉踏实。但如果你后续要批量导入 sward我建议优先选 XML 包。原因不难理解。HTML 导出面向的是“人类阅读”页面里的宏已经渲染成前端组件脚本解析时需要还原的上下文少。XML 导出则是一个相对完整的容器里面同时保留页面实体、原始正文、附件的相对路径和附加元数据机器处理的一致性更好。你可以想象成一份是印刷好的杂志一份是带有排版标注的源文件导入到别的系统显然源文件更可控。实际操作时先把空间导出下载下来然后解压到一个干净目录mkdir -p export unzip confluence-space-export.zip -d export/ find export/ -maxdepth 2 | head -30解压后常见结构类似这样export/ ├── entities.xml ├── index.html ├── images/ └── attachments/有些版本会额外生成pages/目录里面是每个页面的 HTML 快照以及dita/目录这都不影响核心文件还是entities.xml和附件目录。需要特别提醒的是导出包通常包含页面历史版本。如果你的目标只是把当前状态迁走那些旧版本不仅会拖慢解析速度还可能让同一个页面出现多条记录导致后续创建页面时产生重复。建议在解析阶段就过滤掉非 current 版本的实体只保留最新状态。2.2 在解压包里找出“页面地图”Confluence 的页面关系是典型的树形结构一个 Space 有一个 Home 页面下面挂着若干父页面父页面下面再有子页面。导入 sward 时这个树形结构必须保留下来否则所有页面都会变成平级导航完全失去意义。在 XML 导出里每个页面实体一般会带几个关键字段页面 ID、标题、父页面 ID、正文内容、同级排序位置。用 Python 的 ElementTree 可以快速把这些信息抽出来import xml.etree.ElementTree as ET tree ET.parse(export/entities.xml) root tree.getroot() for entity in root.iter(entity): page_id entity.get(id) title entity.findtext(title) parent_id entity.findtext(parentId) print(page_id, title, parent_id)具体标签名和你们 Confluence 版本的 XML schema 可能略有差异但思路一致。拿到这些数据后再依据 parentId 建树最后根据排序字段调整页面顺序生成一份类似这样的目录清单页面标题源页面 ID父页面 ID同级顺序团队知识库 Home1001无0研发规范100210011编码风格100310021上线检查清单100410012这一步看起来不起眼但它决定了后面 API 导入时父页面能不能对得上。sward 的导航是依据父子关系自动生成的要是源端不先把 parentId 清清楚导入之后就得手动拖拽调整一两百个页面拖一遍基本就没有继续迁移的热情了。3. 解析Confluence存储格式把“宏”翻译成Markdown3.1 Confluence 存储格式不是标准 HTMLConfluence 页面导出后的正文并不是普通 HTML里面掺杂了大量ac:命名空间的标签用来表示各种宏组件。比如提醒面板、代码块、目录、面板等都是以结构化宏的形式存在。典型片段长这样ac:structured-macro ac:nameinfo ac:parameter ac:nametitle注意事项/ac:parameter ac:rich-text-body p发布前请检查数据库备份。/p /ac:rich-text-body /ac:structured-macro这些标签在浏览器里会被 Confluence 渲染成漂亮的提示框但进入 sward 之前必须翻译成 Markdown 能表达的东西。最常见的映射表可以这样定Confluence 宏含义转换后info信息提示框Markdown 引用块或提示块warning警告框引用块或加粗警告块note注意引用块code代码块围栏代码块toc目录删除sward 自动生成目录panel通用面板引用块section/column分栏布局降级为普通段落或表格翻译时最忌讳的做法是直接拿正则去匹配所有ac:...标签。因为宏可能嵌套很深的子节点某些宏内部还包含代码示例示例里也有尖括号一个正则打天下会搞得满目疮痍。3.2 推荐的处理管线占位符 Pandoc 反向替换我验证下来比较顺手的管线是这样先用脚把 HTML body 里的结构化宏挖出来换成正式文本不会出现的临时占位符比如{{{MACRO_INFO_1}}}再把干净的 HTML 交给 Pandoc 转成 Markdown最后把 Markdown 里的占位符替换成目标语法。第一段脚本的逻辑大致是from bs4 import BeautifulSoup soup BeautifulSoup(html_body, html.parser) counter 0 for macro in soup.find_all(ac:structured-macro): counter 1 macro_name macro.get(ac:name) macro.replace_with(f{{{{MACRO_{macro_name.upper()}_{counter}}}}})接着用 Pandoc 转换pandoc -f html -t gfm page.html -o page.mdPandoc 对标准表格、段落、代码块、标题的处理都比较成熟比自研几百行正则稳定得多。转换完成后再通过一个映射字典把占位符还原成 Markdown 语法markdown markdown.replace({{{MACRO_INFO_1}}}, 注意发布前请检查数据库备份。) markdown markdown.replace({{{MACRO_CODE_1}}}, text\n示例代码\n)网上有很多现成的脚本可以加速这一步但要记得宏在不同 Confluence 插件里会有不同名称比如jira、excerpt、spinner。尤其jira宏嵌入了问题列表直接转换出来没有任何意义应该在源端提前人工导出 Jira 数据再决定要不要在 sward 里以表格形式保留。这类“无法无损还原”的内容迁移前就得和团队商量好降级方案而不是等导入完再补救。3.3 图片和附件路径整理URL 编码是第一个坑Confluence 导出的图片引用经常是这种形式img src/download/attachments/12345/产品截图 2024.png /也就是说真正要迁移的附件文件放在attachments/目录里但 HTML 里的路径和目录结构不一定一一对应。处理时我习惯把附件统一归到一个目录并保持文件名不变比如space/ ├── 首页.md └── assets/ └── 12345/ └── 产品截图 2024.png然后 Markdown 里的引用统一写成./assets/12345/产品截图 2024.png或者加../。这里有个必须处理的细节文件名里带有中文和空格时API 请求、URL 解析环节很容易出问题。稳妥做法是先做一次 URL 编码import urllib.parse filename 产品截图 2024.png safe_name urllib.parse.quote(filename, safe) print(safe_name) # %E4%BA%A7%E5%93%81%E6%88%AA%E5%9B%BE%202024.png我第一轮迁移时就是没做这一步结果二十多个页面的图片全部无法显示。原因是 API 网关或静态资源服务器会按 URL 规范严格解析空格和未编码中文容易被拦截导致 400 或 403。记住附件名落盘时可以用原始文件名但链路里出现 URL 时必须是编码后的形态。4. 批量导入sward先建页面树再递归创建页面4.1 REST 接口空间、页面、附件三类资源把 Confluence 内容转换成 Markdown 后剩下的工作就是把 Markdown 文件送进 sward。sward 本身以空间为核心组织内容所以 API 设计一般围绕三类资源展开。以 sward 的 REST API 为例通常会有这样几个用途接近的端点获取空间列表GET /api/v1/spaces创建页面POST /api/v1/pages上传附件POST /api/v1/pages/{pageId}/attachments创建页面的请求体一般长这样{ spaceId: 1024, parentId: null, title: 首页, body: # Markdown 内容, format: markdown }具体路径和字段以你们部署的 sward 环境接口文档为准但主要逻辑都一样。写一个最小 Python 导入函数并不复杂import requests API https://sward.example.com/api/v1 HEADERS {Authorization: Bearer token} def create_page(space_id, parent_id, title, body): resp requests.post( f{API}/pages, headersHEADERS, json{ spaceId: space_id, parentId: parent_id, title: title, body: body, format: markdown, }, timeout30, ) if resp.status_code not in (200, 201): with open(import_errors.log, a, encodingutf-8) as f: f.write(f{title} {resp.status_code} {resp.text}\n) return None return resp.json().get(id)4.2 不要直接扫目录先按层级元数据递归有些团队的迁移脚本是直接遍历本地 Markdown 文件文件名当作页面标题目录名当作父页面。这种做法在小规模场景下可行但遇到 Confluence 这样结构复杂的源系统时会出问题。因为有些父子关系可能并不对应目录深度比如多个子页面在导出时被拍平成同一目录但源端实际上挂在不同父页面下。所以最稳妥的是前面第 2.2 节生成的“页面地图”把它保存成一份 YAML 或 JSON作为导入顺序的元数据root: title: 团队知识库 order: - title: 研发规范 children: - title: 编码风格 - title: Git 分支约定 - title: 上线检查清单接着按这个结构递归调用创建接口。每个节点的parentId都取上一层创建后返回的真实 ID既能保持层级又能确保顺序符合原空间。def import_tree(space_id, parent_id, tree_node): title tree_node.get(title) body load_markdown_by_title(title) new_page_id create_page(space_id, parent_id, title, body) if new_page_id is None: # 即使失败也继续兄弟节点导入错误留在日志里 new_page_id None for child in tree_node.get(order, []): import_tree(space_id, new_page_id, child)这种递归写法的好处是简单直白坏处是如果中间有节点失败它的所有子节点会一起变成孤儿。所以通常我会在树节点结构里先让失败的节点返回一个特殊 ID而不是None保证子节点仍然挂在正确的祖先下面等重试时再补挂。4.3 幂等控制防止重跑脚本产生重复页面导入脚本第一次跑中断了再跑一遍发现页面重复了一半这种情况很常见。解决办法是维护一个本地映射表把源页面 ID 和目标页面 ID 的关系持久化。我习惯用一个 SQLite 文件记录CREATE TABLE page_mapping ( source_id INTEGER PRIMARY KEY, target_id INTEGER, status TEXT, updated_at TEXT );每次创建页面之前先查一下映射表命中了就跳过没命中才调用 API。这样一来即使脚本中途断了、网络超时、API 限流都可以安全重跑不会制造垃圾页面。映射表还有另一个用处。Confluence 页面之间存在站内链接导入后需要把旧链接改成新地址直接依赖映射表就能把old_id - new_page_url的对应关系生成出来。所以它不是辅助工具而是整个迁移工程的底盘。5. 导入后修复链接、对账附件、映射权限5.1 站内链接重写旧 URL 不会自己变成新地址Confluence 内部链接通常有两种形态一种指向/pages/viewpage.action?pageId12345另一种指向/display/DEV/PageTitle。这些链接进入 sward 之后如果不重写用户点过去就会跳到原系统一旦 Confluence 下线或关闭外网访问文档就变成一堆断链。重写原理很简单用映射表生成“旧标识符 - 新相对路径”的字典然后在每个 Markdown 文件里批量替换。LINK_MAP { viewpage.action?pageId12345: /doc/dev/code-review, display/DEV/CodeReview: /doc/dev/code-review, } def rewrite_links(text: str) - str: for old, new in LINK_MAP.items(): text text.replace(old, new) return text实际项目里旧 URL 的格式可能不统一有些页面直接用相对路径../display/DEV/...有些是带域名全链接。建议解析阶段把所有链接归一化成绝对 URL再集中处理。我第一轮就是因为混着写漏掉十几个链接后来靠整站扫描才抓出来。5.2 附件对账图片能不能显示不能靠感觉如果页面很多附件是否全部导入成功最好的办法是自动对账而不是肉眼抽查。具体做法是在一张任务表里记录每个源页面的附件数量每导入一个附件就递增计数最后比较差异。可以用这张表作为验收依据检查项预期异常处理附件数量上传前后数量一致差异项目重新上传页面内图片请求状态HTTP 200检查 URL 编码和相对路径页面链接解析无 404用映射表重写大附件可下载确认附件模块可用我迁移时发现有个页面包含几十个 PDF之前只传了页面没传附件导致文档明明在附件却全部丢失。后来就是靠对账脚本一条条找补回来。不要把“上传时成功”当作最终结果以对账数据一致作为导入完成的定义。5.3 权限和用户组映射最容易忽略的“隐形数据”Confluence 权限模型通常包含查看、注释、编辑、删除、管理权限。sward 这类轻量知识库一般不会复制这么细的权限通常只区分空间级的管理者、编辑者和只读访客。所以迁移时需要一份权限映射建议而不能简单把所有用户都设成管理员。可以按这个方向梳理Confluence 权限sward 角色建议View访客全员可读Comment / Edit编辑者核心内容负责人Admin / 空间管理管理员控制在一到两人这里要特别提醒直接照搬权限配置不是好事因为源系统可能积累了大量陈旧账号。借迁移窗口重新梳理一遍权限反而能让知识库更清爽。要是迁移完发现几十个管理员没几天就有人误删页面或改乱结构到时候再去权限审计就晚了。6. 实际迁移中一定会踩的坑我替你踩过了6.1 中文文件名和空格编码不做图片全挂前面说过附件名要做 URL 编码。我再补充一个现场表现如果你在上传附件时没有编码有时候接口会返回成功但页面上图片引用却是坏的。这是因为你本地上传用的路径和最终渲染出来的 URL 不是同一个字符串浏览器按新 URL 去请求自然找不到文件。可靠做法是上传成功后拿接口返回的附件地址回填 Markdown而不是自己拼路径。6.2 宏里嵌套代码块转义符号会把内容弄得一团糟Confluence 的code宏经常用来展示 XML、JSON 或其他带尖括号的代码。导出后在 HTML 里这些尖括号会被实体化比如lt;scriptgt;。如果直接转 MarkdownPandoc 有时会保留实体有时会还原成真实符号不一致的表现很难看。建议在转换前统一把代码宏内容做一轮实体反转再交给 Pandoc。代码块内部最好不要做任何额外处理让它保持原样进入围栏代码块。6.3 大页面超时和多附件失败要有重试机制超大页面在导出后可能达到 1MB 以上正文一次性提交 API 很容易超时。遇到这种情况可以把页面按 Markdown 二级标题拆成多个段落分次调用更新接口。另外附件上传请求经常被限流建议给每个请求加上指数退避重试第一次失败等 2 秒第二次等 4 秒最多重试三次。不是为了偷懒而是防止 Confluence 源系统在频繁读取附件时也出现延迟。6.4 空白占位页面别小看只读项目的导航体验Confluence 里有些页面只有一个标题没有正文作用是在目录里占位。导入 sward 后这些空白页面也会成为导航节点用户点进去一片空白体验很差。迁移前应该人工梳理一遍看看哪些页面只是临时容器哪些合并到父页面描述里更合理。不要为了“保留原样”而保留迁移是重新整理知识库的好机会。7. 迁移完成之后把迁移日志当资产来维护迁移项目收尾不是“导入完成”四个字而是一份可追溯的记录。我会把整个过程维护成一个migration_manifest.md文档里面至少包含一张表阶段时间结果备注Confluence 导出2025-01-05成功200 页含附件格式转换2025-01-06成功216 个 md 文件批量导入2025-01-06部分失败10 个页面超时重新导入2025-01-07成功需重写链接附件对账2025-01-08通过230/235补传 5 个这张表的价值会在后续体现某个页面 404 了你直接查日志就能定位是哪个源页面有没有完成附件上传团队想清理旧内容也能快速知道哪些页面是迁移来的、哪些是迁移后新建的。迁移过程本身也是知识和最终成果一样值得维护。我个人做知识库迁移的感受是一次性的脚本往往做到一半就想放弃但把迁移当作一个小项目来运营把输入、输出、失败重试、映射表都理顺之后下一次再迁其他空间就只是换参数的事。第 2 个空间可能半天搞定第 3 个空间可能只需要改改脚本路径。数据导入从来不是最难的难的是你想不想把这条链路真正走通以及愿不愿意为那些细节多留一点耐心。