随着我们团队沉淀的架构设计、SOP 和项目文档呈指数级增长。传统的 Confluence 显得日益笨重,且闭源的数据结构对大模型知识库的后续接入极不友好。
更棘手的是,受限于极其严格的数据安全合规要求,市面上依赖公网云端中转的 SaaS 迁移工具全部不可用。
本文记录了我们在完全隔离的纯内网环境下,通过"物理直连数据库与磁盘"的硬核方式,将 20GB 的Confluence 9.2的文档,迁移至基于 Git 和 Markdown 的Wiki.js的全过程。并附带完整的脚本。
一、为什么选择"物理直连"?
开始是选择导出成html文件,然后通过富文本转换,但是 导出的 HTML 格式带有大量 Atlassian 专有标签,难以被标准 Markdown 解析器干净地处理。而且附件没办法很好的处理,最终尝试多次放弃该方案。尝试彻底绕过 Confluence 的应用层,直接在内网宿主机上连入其 MySQL 底层数据库 读取页面正文结构,并挂载宿主机的 /attachments 物理文件夹进行比对。最后将转换后的纯血 Markdown 和提取出的附件直接推送到内网自建的GitLab,利用 Wiki.js 的 Git Sync 机制完成无缝吸入。
二、血泪踩坑录:看似简单的目录树,暗藏四大杀机
在打磨迁移脚本的过程中,我们遭遇了几个极其底层的"幽灵 Bug",如果你的团队也在做类似迁移,请务必避开这四大天坑:
坑一:Confluence 附件架构的"时空错位"(V3 vs V4)
Confluence 底层对附件的存储架构经过了迭代:
-
老版本 (V3) :文件直接以版本号命名,纯数字(如
1,2)。 -
新版本 (V4,如 9.2) :文件以
ID.版本号命名(如360453.1)。 如果没有兼顾这两种格式,你在物理磁盘上将面临 0% 的附件找回率。我们的脚本通过智能回退机制,完美兼容了跨版本的数据格式。
坑二:Wiki.js 的致命报错 EISDIR
测试导入内网 Wiki.js 时,Node.js 底层引擎爆出了经典的 EISDIR: illegal operation on a directory, read 错误,导致几千篇文档静默失败。 原因极其反常识 :由于我们将页面标题作为文件夹名,如果有同事的页面恰好叫 接口规范.md 或 README.html,磁盘上就会生成带有代码后缀的文件夹。Wiki.js 扫描时误将其当成文本文件去读取,当场崩溃。
坑三:不可见字符导致的路径"哈希碰撞"
内网文档的标题常包含全角空格( )、制表符或 。Wiki.js 在生成 URL Path 时,会将普通空格转换为 -。这就导致原本不一样的标题(比如 API 接口 和含有特殊空格的 API接口)可能被映射到同一个底层路径,直接引发 Git 树解析碰撞崩溃。必须用正则彻底绞杀所有不可见字符。
坑四:变态的日期格式强迫症
Wiki.js 的 Frontmatter (YAML 头) 对日期格式有着严苛的洁癖。如果传入类似 2025-10-12 14:30:00(MySQL 默认格式),Wiki.js 会直接拒绝收录该页面。必须在导出时严格格式化为 ISO 8601 标准(如 2025-10-12T14:30:00Z)。
三、终极解药:无损迁移 Python 完整脚本
基于以上深坑,我们打磨出了这套Python 迁移脚本。它集成了正则深度清洗、自动补充 editor: markdown 属性、ISO 时间格式化、防 EISDIR 保护等全部功能。
由于是内网环境,你可以将以下代码保存为 export_script.py,并将其与离线下载好的 Python Docker 镜像(包含 mysql-connector-python, beautifulsoup4, markdownify, tqdm)一起在内网服务器运行。
迁移代码:
python
import os
import re
import shutil
import sys
import mysql.connector
import datetime
from pathlib import Path
# 彻底关闭 tqdm 后台线程,解决 atexit 报错
from tqdm import tqdm
tqdm.monitor_interval = 0
from bs4 import BeautifulSoup
from markdownify import markdownify as md
# 突破默认递归限制,防止超深层级页面报错
sys.setrecursionlimit(2000)
# ================= 配置区 =================
DB_CONFIG = {
'user': os.getenv('DB_USER', 'root'),
'password': os.getenv('DB_PASS', '123456'),
'host': os.getenv('DB_HOST', '127.0.0.1'),
'port': int(os.getenv('DB_PORT', 3306)),
'database': os.getenv('DB_NAME', 'confluence'),
'charset': 'utf8mb4',
'use_pure': True
}
ATTACHMENT_SOURCE_DIR = Path('/app/attachments')
OUTPUT_DIR = Path('/app/output')
REPORT_FILE = OUTPUT_DIR / 'migration_report.txt'
# ==========================================
STATS = {
'page_total': 0, 'page_ok': 0, 'page_err': 0,
'att_total': 0, 'att_ok': 0, 'att_err': 0, 'att_miss': 0,
'details': {'page_errors': [], 'att_errors': [], 'att_missing': []}
}
ATTACHMENT_DIR_CACHE = {}
def pre_flight_check():
"""环境自检雷达"""
print("📡 正在执行环境自检...")
if not ATTACHMENT_SOURCE_DIR.exists():
return False, f"严重错误: 容器内不存在 {ATTACHMENT_SOURCE_DIR} 目录!"
file_count = sum(len(files) for _, _, files in os.walk(ATTACHMENT_SOURCE_DIR))
if file_count == 0:
return False, f"严重错误: {ATTACHMENT_SOURCE_DIR} 是空的!请检查 docker 的 -v 挂载路径。"
return True, "环境自检通过!"
def build_attachment_cache():
"""扫描物理磁盘建立高精度索引"""
print("🔍 正在扫描物理磁盘建立附件索引...")
count = 0
for root, dirs, files in os.walk(ATTACHMENT_SOURCE_DIR):
dir_name = os.path.basename(root)
if dir_name.isdigit():
ATTACHMENT_DIR_CACHE[dir_name] = root
count += 1
print(f"✅ 索引建立完成!共发现 {count} 个附件物理文件夹。")
def get_attachment_file(original_id, current_id, target_version):
"""完美兼容 Confluence V3 和 V4 附件架构"""
target_version_str = str(target_version)
for folder_id in [str(original_id), str(current_id)]:
physical_dir = ATTACHMENT_DIR_CACHE.get(folder_id)
if not physical_dir:
continue
physical_path = Path(physical_dir)
# 1. 尝试匹配 V4 格式 (例如: 360453.1)
v4_target = physical_path / f"{folder_id}.{target_version_str}"
if v4_target.is_file(): return v4_target
# 2. 尝试匹配 V3 格式 (例如: 1)
v3_target = physical_path / target_version_str
if v3_target.is_file(): return v3_target
# 3. 容错:找该文件夹下数字最大的版本
v4_versions = []
for f in physical_path.iterdir():
if f.is_file() and f.name.startswith(f"{folder_id}."):
try: v4_versions.append(int(f.name.split('.')[-1]))
except ValueError: pass
if v4_versions:
return physical_path / f"{folder_id}.{max(v4_versions)}"
v3_versions = [int(f.name) for f in physical_path.iterdir() if f.is_file() and f.name.isdigit()]
if v3_versions:
return physical_path / str(max(v3_versions))
# 4. 极端老版本扁平兼容
for folder_id in [str(original_id), str(current_id)]:
fallback = ATTACHMENT_SOURCE_DIR / folder_id
if fallback.is_file(): return fallback
return None
def sanitize_filename(name):
"""终极目录与文件名清洗器"""
if not name: return "Untitled_Page"
name = str(name)
# 1. 替换路径分隔符
name = name.replace('/', '_').replace('\\', '_')
# 2. 【核心修复】将所有不可见字符、全角空格、连续空格全部替换为单一下划线
name = re.sub(r'\s+', '_', name)
# 3. 去除系统级非法字符
clean_name = re.sub(r'[?:"<>|*]', "", name).strip('_')
clean_name = re.sub(r'[\x00-\x1f]', '', clean_name)
if not clean_name: clean_name = "Untitled_Page"
# 4. 【核心修复】防止 EISDIR 崩溃。如果文件夹名字正好以 .md 等结尾,强制加上后缀
lower_name = clean_name.lower()
if lower_name.endswith(('.md', '.html', '.json', '.yml')):
clean_name += "_page"
return clean_name[:200]
def get_unique_path(directory, filename):
file_path = directory / filename
if not file_path.exists(): return filename
stem, suffix = file_path.stem, file_path.suffix
counter = 1
while True:
new_filename = f"{stem}_{counter}{suffix}"
if not (directory / new_filename).exists(): return new_filename
counter += 1
def process_html_to_markdown(html_body, attachment_list):
if not html_body: return ""
try:
soup = BeautifulSoup(html_body, 'lxml')
except:
soup = BeautifulSoup(html_body, 'html.parser')
for tag in soup(['script', 'style', 'iframe', 'meta', 'link']): tag.decompose()
for img in soup.find_all('img'):
if not img.get('alt'): img['alt'] = 'image'
keep_tags = ['table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
'caption', 'colgroup', 'col', 'u', 'sub', 'sup', 'details', 'summary']
try:
md_text = md(str(soup), heading_style="ATX", keep=keep_tags)
except Exception:
md_text = html_body
if attachment_list:
md_text += "\n\n---\n### 📎 附件/图片列表\n"
for att in attachment_list:
md_text += f"- 📄 [{att['filename']}](./attachments/{att['filename']})\n"
return md_text
def format_iso_date(date_obj):
"""【核心修复】将时间格式化为 Wiki.js 强迫症所要求的 ISO 8601 标准"""
if not date_obj:
return datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
if isinstance(date_obj, datetime.datetime):
return date_obj.strftime('%Y-%m-%dT%H:%M:%SZ')
# fallback 处理字符串形式的时间
return str(date_obj).replace(' ', 'T') + 'Z'
def generate_report():
report_content = f"""=========================================
CONFLUENCE 数据迁移详细报告
=========================================
📄 页面总数:{STATS['page_total']} | ✅ 成功:{STATS['page_ok']} | ❌ 失败:{STATS['page_err']}
📎 附件总数:{STATS['att_total']} | ✅ 成功:{STATS['att_ok']} | ⚠️ 丢失:{STATS['att_miss']} | ❌ 报错:{STATS['att_err']}
=========================================\n"""
if STATS['details']['att_missing']:
report_content += f"\n⚠️ 【丢失附件明细 - 挂载异常或物理文件被删】 ({len(STATS['details']['att_missing'])})\n"
for err in STATS['details']['att_missing']: report_content += f"- {err}\n"
if STATS['details']['att_errors']:
report_content += f"\n❌ 【报错附件明细】\n"
for err in STATS['details']['att_errors']: report_content += f"- {err}\n"
if STATS['details']['page_errors']:
report_content += f"\n❌ 【报错页面明细】\n"
for err in STATS['details']['page_errors']: report_content += f"- {err}\n"
with open(REPORT_FILE, 'w', encoding='utf-8') as f:
f.write(report_content)
def main():
print("🚀 开始数据迁移程序...\n")
ok, msg = pre_flight_check()
if not ok:
print(msg)
return
print(msg)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
build_attachment_cache()
try:
conn = mysql.connector.connect(**DB_CONFIG)
cursor = conn.cursor(dictionary=True)
except Exception as e:
print(f"❌ 数据库连接失败: {e}")
return
try:
print("📖 正在读取数据库索引...")
cursor.execute("""
SELECT c.contentid, c.parentid, c.title, bc.body, s.spacename, c.lastmoddate
FROM CONTENT c JOIN BODYCONTENT bc ON c.contentid = bc.contentid
LEFT JOIN SPACES s ON c.spaceid = s.spaceid
WHERE c.contenttype = 'PAGE' AND c.content_status = 'current' AND c.prevver IS NULL
""")
all_pages = cursor.fetchall()
cursor.execute("""
SELECT
MIN(contentid) as original_id,
MAX(contentid) as current_id,
MAX(version) as max_version,
title as filename,
pageid
FROM CONTENT
WHERE contenttype = 'ATTACHMENT'
GROUP BY pageid, title
""")
all_attachments = cursor.fetchall()
finally:
if conn.is_connected():
cursor.close()
conn.close()
pages_map = {}
for p in all_pages:
pid = str(p['contentid'])
pages_map[pid] = {
'id': pid,
'parent_id': str(p['parentid']) if p['parentid'] else None,
'title': p['title'],
'body': p['body'],
'space': p['spacename'] or "未分类空间",
'date': format_iso_date(p['lastmoddate'])
}
page_attachments = {}
for att in all_attachments:
pid = str(att['pageid'])
if pid not in page_attachments: page_attachments[pid] = []
page_attachments[pid].append({
'original_id': str(att['original_id']),
'current_id': str(att['current_id']),
'max_version': str(att['max_version'] or 1),
'filename': sanitize_filename(att['filename'])
})
STATS['page_total'] = len(all_pages)
for att_list in page_attachments.values():
STATS['att_total'] += len(att_list)
children_map = {}
roots = []
for pid, p in pages_map.items():
parent_id = p['parent_id']
if parent_id and parent_id in pages_map:
if parent_id not in children_map: children_map[parent_id] = []
children_map[parent_id].append(pid)
else:
roots.append(pid)
space_groups = {}
for rid in roots:
sname = pages_map[rid]['space']
if sname not in space_groups: space_groups[sname] = []
space_groups[sname].append(rid)
def export_recursive(page_id, current_dir):
page = pages_map[page_id]
safe_title = sanitize_filename(page['title'])
page_dir = current_dir / safe_title
page_dir.mkdir(parents=True, exist_ok=True)
att_list = page_attachments.get(page_id, [])
if att_list:
att_dir = page_dir / "attachments"
att_dir.mkdir(exist_ok=True)
for att in att_list:
src = get_attachment_file(att['original_id'], att['current_id'], att['max_version'])
if src:
final_name = get_unique_path(att_dir, att['filename'])
try:
shutil.copy2(src, att_dir / final_name)
att['filename'] = final_name
STATS['att_ok'] += 1
except Exception as e:
STATS['att_err'] += 1
STATS['details']['att_errors'].append(f"{att['filename']} - Error: {e}")
else:
STATS['att_miss'] += 1
STATS['details']['att_missing'].append(f"{att['filename']} (页面: {page['title']}) - Original ID: {att['original_id']}")
try:
content = process_html_to_markdown(page['body'], att_list)
# 【核心修复】加入 editor 属性,保障 Wiki.js 正确识别编辑器
front_matter = f"---\ntitle: {page['title']}\npublished: true\ndate: {page['date']}\neditor: markdown\n---\n\n"
with open(page_dir / "README.md", 'w', encoding='utf-8') as f:
f.write(front_matter + content)
STATS['page_ok'] += 1
except Exception as e:
STATS['page_err'] += 1
STATS['details']['page_errors'].append(f"{page['title']} - Write Error: {e}")
if page_id in children_map:
for child_id in children_map[page_id]:
export_recursive(child_id, page_dir)
print("\n📦 开始提取页面与附件,构建目录树...")
for space_name, root_ids in space_groups.items():
space_dir = OUTPUT_DIR / sanitize_filename(space_name)
for rid in tqdm(root_ids, desc=f"空间 [{space_name[:10]}]", leave=False):
export_recursive(rid, space_dir)
generate_report()
print("\n=========================================")
print(" 🎉 迁移任务全部完成!")
print(f" 📄 页面转换率 : {STATS['page_ok']}/{STATS['page_total']} ({(STATS['page_ok']/STATS['page_total']*100 if STATS['page_total'] else 0):.1f}%)")
print(f" 📎 附件恢复率 : {STATS['att_ok']}/{STATS['att_total']} ({(STATS['att_ok']/STATS['att_total']*100 if STATS['att_total'] else 0):.1f}%)")
print("=========================================\n")
if __name__ == "__main__":
main()
因为内网python环境不全,因此做了一个镜像:
python
# 使用 Python 3.9 Slim (兼容性最好,体积适中)
FROM python:3.9-slim
# 设置工作目录
WORKDIR /app
# 1. 换源 (使用清华源加速下载)
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
# 2. 安装系统级依赖 (这是最关键的!)
# 很多 Python 库 (如 Pillow, lxml, pandas) 需要底层 C 库支持
# 如果不装这些,在内网运行某些高级功能时会直接报错
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
g++ \
libxml2-dev \
libxslt-dev \
libjpeg-dev \
zlib1g-dev \
libpq-dev \
default-libmysqlclient-dev \
&& rm -rf /var/lib/apt/lists/*
# 3. 安装 Python "全家桶" 依赖
# 我按功能分类整理,确保万无一失
RUN pip install --no-cache-dir \
# === 数据库连接类 ===
mysql-connector-python \
pymysql \
SQLAlchemy \
psycopg2-binary \
# (SQLAlchemy 是万能 ORM,pymysql 是纯 Python 驱动,备用防坑)
\
# === 网页与文本解析类 ===
markdownify \
beautifulsoup4 \
lxml \
html5lib \
# (lxml 解析速度快,html5lib 容错率极高,专治各种乱码 HTML)
\
# === Excel 与数据处理类 ===
pandas \
openpyxl \
xlrd \
numpy \
# (万一你需要把导出结果整理成 Excel 表格汇报,或者处理 CSV)
\
# === 图片处理类 ===
Pillow \
# (万一你需要压缩附件图片,或者转换图片格式)
\
# === 网络请求类 ===
requests \
urllib3 \
# (万一你想把数据直接通过 API 推送到 Wiki.js 而不是存文件)
\
# === 实用工具类 ===
tqdm \
chardet \
python-dotenv \
pathlib
# (tqdm 是进度条,大量数据迁移时非常有用;chardet 用于检测乱码编码)
# 4. 创建挂载点
RUN mkdir -p /app/attachments /app/output
# 5. 默认命令
CMD ["python", "run.py"]
运行命令:
python
docker run --rm \
--network host \
-v /你的/脚本路径/export_script.py:/app/run.py \
-v /你的/真实附件目录:/app/attachments \
-v /你的/输出目录:/app/output \
-e DB_PASS='你的密码' \
python-migration-toolbox
四、内网 GitLab 提交的策略
20GB 的纯净 Markdown 和附件生成后,如果直接 git push 到内网的 GitLab,极大概率会因为 HTTP 缓冲区爆满而被 Nginx 或 GitLab 服务器直接 Reset。
实战推送策略:
-
本地客户端扩容:在执行 push 操作的内网服务器上,解除 Git 的大小限制。
Bash
git config --global http.postBuffer 2147483648 git config --global http.lowSpeedLimit 0 git config --global http.lowSpeedTime 999999 -
蚂蚁搬家:进入输出的归档目录,按空间(部门或项目组)分批次添加并推送到内网 GitLab。
pythongit add xxxx_page/ git commit -m "docs: 历史文档" git push origin main -
唤醒 Wiki.js :登录内网 Wiki.js 后台,进入 Storage (存储) 模块,点击对应 Git 配置的 Force Sync -> Import Everything。
⚠️ 高能预警 :对于这种绕过网页端直接塞入底层的大量文件突变,普通的 Sync 增量同步会直接忽略。必须使用
Import Everything,强制引擎重建全站知识树。最后,因为版本的细微差异,可以借助大模型进行相应的微调。