随着团队架构设计、SOP流程与项目文档的持续膨胀,Confluence的笨重与封闭数据结构已成为知识库建设与AI接入的瓶颈。尤其在严格的数据安全合规约束下,依赖公网中转的SaaS迁移工具完全失效。本文将完整复盘我们在完全隔离的纯内网环境中,通过物理直连数据库与磁盘的硬核方案,将20GB的Confluence 9.2文档无损迁移至基于Git与Markdown的Wiki.js全过程,并附赠可直接落地的迁移脚本。

一、为何选择物理直连数据库?

迁移初期,我们曾尝试将Confluence页面导出为HTML,再通过富文本转换工具进行格式转换。然而,导出的HTML中充斥着大量Atlassian专有标签,标准Markdown解析器根本无法干净处理,附件资源的处理更是灾难。多次尝试后,我们毅然放弃该方案。

最终采用的策略是彻底绕过Confluence应用层:直接在内网宿主机上连接其底层MySQL数据库,读取页面正文的存储结构,同时挂载宿主机上的附件物理目录进行数据比对。转换生成的纯净Markdown文件与提取出的附件,直接推送到内网自建的GitLab,利用Wiki.js的Git Sync机制完成无缝吸入。这一方案不仅绕开了应用层限制,还大幅提升了迁移效率与数据完整性。

二、血泪踩坑录:目录树背后的四大隐形杀手

在打磨迁移脚本的过程中,我们遭遇了多个极其隐蔽的底层Bug。如果你的团队也在规划类似迁移,以下四大天坑请务必提前规避:

坑一:Confluence附件架构的版本错位(V3 vs V4)

Confluence底层对附件的存储架构经历了多次迭代:

  • 老版本(V3):文件直接以纯数字版本号命名,如12
  • 新版本(V4,如9.2):文件以带哈希前缀的命名方式存储,如ID.版本号360453.1

若未同时兼容这两种格式,物理磁盘上的附件找回率将直接归零。我们的脚本通过智能回退机制,成功兼容了跨版本的数据格式,确保附件零丢失。

坑二:Wiki.js的致命报错EISDIR

测试导入内网Wiki.js时,Node.js底层引擎爆出经典的EISDIR: illegal operation on a directory, read错误,导致数千篇文档静默失败。排查后发现原因极其反常识:由于我们将页面标题直接用作文件夹名,当同事的页面恰好命名为接口规范.mdREADME.html时,磁盘上便生成了带代码后缀的文件夹。Wiki.js扫描时误将其当作文本文件读取,瞬间崩溃。

坑三:不可见字符引发的路径哈希碰撞

内网文档标题中常混入全角空格()、制表符或 等不可见字符。Wiki.js在生成URL Path时,会将普通空格转换为-(连字符),导致原本完全不同的标题(如API 接口与含有特殊空格的API接口)被映射到同一底层路径,直接触发Git树解析碰撞。必须通过正则表达式彻底清除所有不可见字符,才能保障路径唯一性。

坑四:严苛的日期格式强迫症

Wiki.js的Frontmatter(YAML头)对日期格式有着近乎洁癖的严格要求。若传入MySQL默认格式(如2025-10-12 14:30:00),Wiki.js会直接拒绝收录该页面。必须在导出阶段严格格式化为ISO 8601标准(如2025-10-12T14:30:00Z),否则页面将静默丢失。

三、终极解药:无损迁移Python完整脚本

基于上述深坑,我们打磨出了一套集正则深度清洗、自动补充editor: markdown属性、ISO时间格式化、防EISDIR保护于一体的Python迁移脚本。该脚本已在实际迁移中验证,可处理20GB级别的大规模数据。

由于目标环境为纯内网,你可以将以下代码保存为export_script.py,并与离线下载好的Python Docker镜像(包含mysql-connector-python, beautifulsoup4, markdownify, tqdm等依赖)一同部署到内网服务器执行。

迁移脚本核心代码如下:

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环境依赖不全,我们专门制作了离线镜像,Dockerfile配置如下:

# 使用 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"]

最终运行命令如下,一条指令即可启动全量迁移:

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提交策略与Wiki.js唤醒

20GB的纯净Markdown与附件生成后,若直接执行git push推送到内网GitLab,极大概率因HTTP缓冲区爆满而被Nginx或GitLab服务器直接Reset。实战中我们采用了以下推送策略:

本地客户端扩容

在执行push操作的内网服务器上,先解除Git的HTTP缓冲区大小限制,避免大文件传输中断:

git config --global http.postBuffer 2147483648
git config --global http.lowSpeedLimit 0
git config --global http.lowSpeedTime 999999

蚂蚁搬家:分批提交

进入输出的归档目录,按空间(部门或项目组)分批次添加并推送到内网GitLab,每批次控制在合理大小,确保传输稳定:

git add xxxx_page/
git commit -m "docs: 历史文档"
git push origin main

唤醒Wiki.js同步

推送完成后,登录内网Wiki.js后台,进入Storage(存储)模块,点击对应Git配置的Force Sync → Import Everything,即可触发全量导入:

⚠️ 高能预警:对于这种绕过网页端直接塞入底层的大量文件突变,普通的 Sync 增量同步会直接忽略。必须使用 ,强制引擎重建全站知识树。

五、迁移实践总结与延伸建议

本次迁移实践充分验证了物理直连数据库方案在纯内网环境下的可行性与高效性。相比传统导出导入流程,该方案不仅规避了SaaS工具的网络限制,更从根本上解决了附件丢失、格式错乱等顽疾。迁移后的知识库已成功对接内部大模型,为后续智能化检索与问答系统奠定了坚实的数据基础。

对于同样面临知识库迁移挑战的团队,以下几点建议值得参考:

  • 数据备份先行:迁移前务必对MySQL数据库与附件目录做完整快照,以便异常回滚。
  • 标题清洗前置:在脚本中提前用正则清除所有不可见字符,避免路径碰撞。
  • 分批推送控制节奏:切勿一次性推送全量数据,按业务模块分批更稳妥。
  • 版本差异微调:不同Confluence与Wiki.js版本间存在细微差异,可借助大模型辅助进行针对性调整。

最后,由于版本差异可能带来未知行为,建议在正式迁移前先进行小规模试点验证,再逐步扩大迁移范围。

[AFFILIATE_SLOT_1]

延伸思考:迁移完成后,如何让知识库发挥更大价值?我们已将Wiki.js与内部AI服务对接,通过API实现文档的自动摘要、语义检索与智能问答,大幅提升了团队知识复用效率。未来还将探索基于微服务的知识库架构,将文档服务独立部署,进一步提升系统可扩展性。

[AFFILIATE_SLOT_2]

一句话总结:物理直连数据库 + 脚本化清洗 + 分批推送,是纯内网环境下大规模知识库无损迁移的最优解。

Import Everything