20GB核心知识库无损大迁徙:纯内网环境下从 Confluence 9到 Wiki.js 的踩坑与实践

随着我们团队沉淀的架构设计、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 错误,导致几千篇文档静默失败。 原因极其反常识 :由于我们将页面标题作为文件夹名,如果有同事的页面恰好叫 接口规范.mdREADME.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。

实战推送策略:

  1. 本地客户端扩容:在执行 push 操作的内网服务器上,解除 Git 的大小限制。

    Bash

    复制代码
    git config --global http.postBuffer 2147483648
    git config --global http.lowSpeedLimit 0
    git config --global http.lowSpeedTime 999999
  2. 蚂蚁搬家:进入输出的归档目录,按空间(部门或项目组)分批次添加并推送到内网 GitLab。

    python 复制代码
    git add xxxx_page/
    git commit -m "docs: 历史文档"
    git push origin main
  3. 唤醒 Wiki.js :登录内网 Wiki.js 后台,进入 Storage (存储) 模块,点击对应 Git 配置的 Force Sync -> Import Everything

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

    最后,因为版本的细微差异,可以借助大模型进行相应的微调。

相关推荐
henry1010104 小时前
利用Python一键清理AWS EC2实例
python·云计算·aws
小鸡吃米…4 小时前
TensorFlow - 词嵌入
人工智能·python·tensorflow·neo4j
.小小陈.4 小时前
Python基础语法详解4:函数、列表与元组全解析
开发语言·c++·python·学习
Lun3866buzha4 小时前
【石油泄漏检测】YOLO13-C3k2-RFCBAMConv模型详解与应用
python
yuanmenghao4 小时前
Linux 性能实战 | 第 19 篇:ftrace 内核跟踪入门 [特殊字符]
linux·python·性能优化
花伤情犹在4 小时前
万物皆可自动化:用 Python 摆脱繁琐点击(以企业微信批量退群为例)
python·自动化·gui·脚本
徐同保4 小时前
python项目:Flask 异步改造实战:从同步到异步的完整指南
python
Ulyanov12 小时前
高保真单脉冲雷达导引头回波生成:Python建模与实践
开发语言·python·仿真·系统设计·单脉冲雷达