本文档记录将有道云笔记数据迁移到自建 HedgeDoc v2 fork 的完整过程,涵盖数据源、迁移工具、HedgeDoc 代码修改、部署配置、导入流程及已知问题。任何人按本文档操作即可复现整个迁移。


1. 项目背景

目标是将积累多年的有道云笔记(含 Markdown 正文、图片、附件、文件夹层级)整体迁移到自建部署的 HedgeDoc v2。HedgeDoc 原生仅支持图片上传,不支持任意类型附件,因此需要在其基础上做 fork 改造,使附件(PDF、Excel、压缩包等)也能上传并在笔记中通过链接访问。

迁移数据规模较大(约 1500+ 篇笔记、数千张图片、数百个附件),因此还涉及批量导入脚本、限流豁免、大文件上传等工程化处理。


2. 数据源说明

迁移过程对比了两种数据来源,取长补短。

2.1 PULL 数据(youdaonote-pull 工具拉取)

  • 工具:youdaonote-pull,通过登录 cookie 直拉有道云笔记 API。
  • 输出结构:
    youdao_md_full/
    ├── <分类目录>/              # 对应有道的文件夹层级
    │   ├── 笔记名.md            # Markdown 正文
    │   ├── images/              # 笔记内引用的图片(保留有道原始哈希文件名)
    │   └── attachments/         # 笔记内引用的附件(按引用下载,文件名带 UUID 前缀)
    
  • 特点:正文为可编辑 Markdown,图片/附件按笔记引用下载;未引用的资源不会拉取。

2.2 APP 导出(有道官方 APP 导出)

  • 来源:有道云笔记 APP「导出全部笔记」功能。
  • 输出结构:
    your_mail@163.com_<时间戳>/
    ├── 笔记名.note.pdf           # 每篇笔记用 PDF 渲染(不可编辑)
    ├── 笔记名.note.attach/        # 该笔记的全部附件,保留纯原名(无 UUID 前缀)
    │   └── 附件文件.xlsx
    └── ...
    
  • 特点:正文是不可编辑的 PDF;附件目录 .note.attach 包含该笔记的全部附件(不仅限于被引用的),且文件名为纯原名。

2.3 数据对齐分析

使用 tools/youdao-import/compare_exports.pycompare_attachments.py 对两种导出做对齐分析,结果如下(数量为本次迁移实测):

维度 PULL 数据 APP 导出
笔记正文 1555 篇 Markdown 1264 个 PDF(.note.pdf / .clip.pdf
图片 5744 个(images/ 目录)
附件 365 个引用(attachments/ 目录) 400 个(.note.attach 目录)
  • PULL 笔记数(1555)多于 APP PDF 数(1264),因为 PULL 包含收藏类短笔记,APP 导出未包含。
  • APP 附件总数(400)多于 PULL 附件引用(365),说明 APP 导出能补充 PULL 未引用的附件。

3. 迁移工具

所有迁移工具位于 tools/youdao-import/ 目录。

3.1 import_youdao_md.py — 主导入脚本

youdaonote-pull 输出的 Markdown 目录树批量导入 HedgeDoc,同时处理图片、附件与文件夹结构。

核心能力:

  • 双通道认证:私有 API(文件夹创建/移动、token 管理)走 session 登录;公开 API(笔记增删、媒体上传)走 Bearer token。
  • 文件夹结构重建:递归遍历源目录,按相对路径在 HedgeDoc 中创建同名文件夹树,并把笔记移动到对应文件夹。
  • 图片上传:解析 Markdown 中的 ![alt](images/xxx) 引用,将本地图片上传到 HedgeDoc 媒体库,再用返回的 /media/<uuid> 替换原引用。
  • 附件上传:解析 attachments/xxx 引用,定位本地附件文件(支持 URL 解码、笔记级与根级 attachments 目录查找),上传后替换为 /media/<uuid> 链接。
  • 幂等/续跑:通过 alias 检测笔记是否已存在,已存在则跳过(加 --force 可覆盖)。
  • 限流重试:对 429 响应按 Retry-After 退避重试,对超时按指数退避重试。
  • slug 生成:笔记名经 slugify() 转为 URL 安全别名(中文等非 ASCII 会 fallback 到 note-<md5前8位>)。

用法:

# 方式 A:用账号密码登录(内部会自动创建 token)
python -u import_youdao_md.py youdao_md_full \
  --base-url http://localhost:3031 \
  --username yourname@domain.com \
  --password yourpassword

# 方式 B:直接用已有 token(避免重复登录触发限流)
python -u import_youdao_md.py youdao_md_full \
  --base-url http://localhost:3031 \
  --token <TOKEN>

# 强制覆盖已存在的笔记
python -u import_youdao_md.py youdao_md_full --base-url <URL> --token <TOKEN> --force

输出统计示例:

=== Done in 1234s ===
Notes: 1500, Folders: 80, Images: 5700, Attachments: 350, Skipped: 55
Failures: 19
  note-xxx: attachment not found: attachments/xxx.pdf
  ...

3.2 load_image.sh — ghcr 镜像手动下载脚本

用于在 vps1 上手动拉取 ghcr.io/maifeipin/hedgedoc/{backend|frontend}:develop 镜像并 docker load

为什么需要: ghcr.io 的 blob 下载会 307 重定向到 pkg-containers.githubusercontent.com,该 CDN 域名在部分网络下会被 reset。本脚本通过本地 socks5h://127.0.0.1:18988 代理逐层下载 blob,绕过 reset。

流程:

  1. 通过代理向 ghcr 申请 pull token;
  2. 拉取多架构 image index,解析出 linux/amd64 的 manifest digest;
  3. 拉取该 manifest,提取 config digest 与各 layer digest;
  4. 逐层下载 config 与 layer blob 到临时目录(带重试,校验非空);
  5. docker load 期望格式拼装 manifest.json(含 RepoTags),打包后 docker load

用法:

bash load_image.sh backend
bash load_image.sh frontend

3.3 get_token.sh — 在 vps1 获取 API token

为什么需要: HedgeDoc 对登录接口(/api/private/auth/local/login)有外部 IP 限流,从外部 IP 反复登录会触发 429。在 vps1 本机 127.0.0.1 直接请求可绕过外部 IP 限流。

流程:

  1. http://127.0.0.1:3031/api/private/csrf/token 获取 CSRF token 并保存 cookie;
  2. 携带 CSRF header + cookie 调用登录接口;
  3. 调用 /api/private/tokens 创建一个有效期至 2027-08-04 的 token。

用法:

# 在 vps 上执行
bash get_token.sh
# 输出的 token 可直接传给 import_youdao_md.py --token

3.4 提交前检查与 CI 流程

代码推送到 develop 分支后,GitHub Actions 会自动运行 5 个 CI 检查,必须全部通过才能部署新镜像:

CI 检查 工具 说明
Lint and check format Oxlint + Oxfmt 代码规范与格式检查。Oxlint 检查 lint 规则(0 warnings, 0 errors),Oxfmt 检查代码格式是否正确
REUSE Compliance Check REUSE Tool 开源许可证合规检查
Run tests & build Jest + Turborepo 后端单元测试 + 前端组件测试 + 构建
E2E Tests Jest + NestJS 后端端到端测试(含媒体上传、笔记 CRUD 等)
Docker Docker Buildx 构建 `ghcr.io/maifeipin/hedgedoc/{backend

本地预检命令(在仓库根目录执行,确保提交前通过 CI):

# 格式化修复(自动修复格式问题)
fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs format:fix

# 检查格式(不修改文件,CI 会跑这个)
fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs format

# Lint 检查(CI 会跑这个)
fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs lint

# 构建
fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs build

# 全量测试
fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs test

CI 查看与监控命令:

# 查看最近 CI 运行状态
gh run list -R maifeipin/hedgedoc --limit 6

# 等待某个 run 完成(0=成功,非 0=失败)
gh run watch <run-id> --exit-status

# 查看失败的 CI 日志
gh run view <run-id> -R maifeipin/hedgedoc --log-failed

常见 CI 失败原因与修复:

失败类型 原因 修复
Oxfmt format 失败 代码格式不符合规范 运行 format:fix 后重新提交
Oxlint warnings <a>hrefuseEffect 缺依赖、<button>role='link' 按 lint 提示修复,改用语义正确的 HTML 元素
E2E EACCES 测试目录权限问题(test_uploads 目录残留) ensureDeleted 使用 fs.rm(path, { recursive: true, force: true })

部署流程(CI 全绿后):

# 1. 在 vps 上拉取新镜像
ssh vps "bash /tmp/load_image.sh frontend; bash /tmp/load_image.sh backend"

# 2. 强制重建容器(环境变量变更时必须 --force-recreate)
ssh vps "cd /app/hedgedoc && docker-compose up -d --force-recreate frontend"

# 3. 验证镜像 ID 一致
ssh vps "docker inspect ghcr.io/maifeipin/hedgedoc/frontend:develop --format '{{.Id}}'; docker inspect hedgedoc_frontend_1 --format '{{.Image}}'"

4. HedgeDoc fork 的代码修改

为支持任意类型附件上传与批量导入,对 HedgeDoc v2 做了以下 fork 改动。所有改动均为「放开限制 + 增强健壮性」,不改变原有正常流程。

4.1 后端(backend)

backend/src/media/media.service.ts

改动: isAllowedMimeType 方法直接返回 true,允许任意 MIME 类型上传。

private static isAllowedMimeType(_: string | undefined): boolean {
  // Allow any file type (images, PDFs, documents, archives, etc.)
  return true;
}

目的: 原版仅允许图片 MIME 类型;迁移需上传 PDF、Excel、压缩包等,故放开。saveFile 主流程在类型无法识别(fileTypeResult 为 undefined)时也允许通过。

backend/src/media/backends/filesystem-backend.ts

改动:

  • saveFile:当 fileTypeundefined 时,ext fallback 为 'bin'mime fallback 为 'application/octet-stream',避免原版对 undefined 的解构异常。
  • deleteFile:捕获 ENOENT,视为「文件已删除」直接返回,不再抛 500,方便清理孤儿媒体记录。
  • getFileResponse:读取文件遇到 ENOENT 时抛 NotInDBError(映射为 404)而非 MediaBackendError(映射为 500),让缺失文件表现为「不存在」而非「服务器错误」。

目的: 提升对孤儿记录(媒体记录存在但磁盘文件缺失)的健壮性,避免单条缺失拖垮整篇笔记渲染。

backend/src/security/rate-limiting.ts

改动:getRateLimitConfigByRequest 中,对 /media 前缀路径直接返回 max: Infinity,豁免媒体路由限流。logout 与 monitoring 路由同样豁免(原版即有)。

if (
  path === '/api/private/auth/logout' ||
  path.startsWith('/api/private/monitoring') ||
  path.startsWith('/media')
) {
  return { max: Infinity };
}

目的: 一篇笔记加载时会并发请求数十张图片/附件,若与全局限流计数共享,会迅速耗尽配额导致笔记打不开。媒体访问本身已有 per-note 权限校验(canUserAccessUpload)保护,无需额外限流。

4.2 数据结构升级(DB Schema)

在原版 HedgeDoc v2 的基础上,新增了 4 个数据模型和 2 个数据库迁移,用于支持目录树、标签分类、反向链接和多维表格功能。

新增数据类型定义

文件 数据模型 说明
database/src/types/folder.ts Folder 目录树节点:id、name、parentId、ownerId、isSystem、sortOrder、createdAt、updatedAt
database/src/types/note-link.ts NoteLink 笔记间反向链接:id、fromNoteAlias、toNoteAlias、linkText、createdAt
database/src/types/table.ts Table 多维表格:id、noteAlias、name、rows(jsonb)、columns(jsonb)、createdAt
database/src/types/tag.ts Tag 标签:id、name、color、creatorId、isSystem、createdAt

数据库迁移文件

backend/src/migrations/20260729000000_cloud_notes_schema_v2.js - 创建 7 张新表:

-- 目录表
CREATE TABLE "folder" (
  id SERIAL PRIMARY KEY,
  name VARCHAR NOT NULL,
  "parentId" INTEGER REFERENCES "folder"(id) ON DELETE CASCADE,
  "ownerId" INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
  "isSystem" BOOLEAN NOT NULL DEFAULT false,
  "sortOrder" INTEGER NOT NULL DEFAULT 0,
  "createdAt" TIMESTAMP NOT NULL,
  "updatedAt" TIMESTAMP NOT NULL
);

-- 笔记添加 folderId 列 + GIN 索引
ALTER TABLE "note" ADD COLUMN "folderId" INTEGER REFERENCES "folder"(id);
CREATE INDEX "note_folderId_idx" ON "note"("folderId");

-- 标签表
CREATE TABLE "tag" (
  id SERIAL PRIMARY KEY,
  name VARCHAR NOT NULL,
  color VARCHAR,
  "creatorId" INTEGER REFERENCES "user"(id),
  "isSystem" BOOLEAN NOT NULL DEFAULT false,
  "createdAt" TIMESTAMP NOT NULL,
  UNIQUE("name", "creatorId")
);

-- 笔记-标签关联表
CREATE TABLE "note_tag" (
  "noteAlias" VARCHAR REFERENCES "note"("publicId") ON DELETE CASCADE,
  "tagId" INTEGER REFERENCES "tag"(id) ON DELETE CASCADE,
  PRIMARY KEY("noteAlias", "tagId")
);

-- 笔记链接表(反向链接)
CREATE TABLE "note_link" (
  id SERIAL PRIMARY KEY,
  "fromNoteAlias" VARCHAR NOT NULL REFERENCES "note"("publicId") ON DELETE CASCADE,
  "toNoteAlias" VARCHAR NOT NULL,
  "linkText" VARCHAR,
  "createdAt" TIMESTAMP NOT NULL
);

-- 多维表格表
CREATE TABLE "note_table" (
  id SERIAL PRIMARY KEY,
  "noteAlias" VARCHAR NOT NULL REFERENCES "note"("publicId") ON DELETE CASCADE,
  name VARCHAR NOT NULL,
  rows JSONB NOT NULL DEFAULT '[]',
  columns JSONB NOT NULL DEFAULT '[]',
  "createdAt" TIMESTAMP NOT NULL,
  "updatedAt" TIMESTAMP NOT NULL
);

backend/src/migrations/20260730000000_add_creator_id_to_tags.js - 为标签表补充 creatorId 索引。

新增后端模块

模块 路径 功能
Folders backend/src/folders/ 目录 CRUD、树构建(递归 children)、笔记移动到目录、noteCount 统计
Tags backend/src/tags/ 标签 CRUD、预设系统标签(isSystem)、按使用频率分组(active/unused)
Links backend/src/links/ Wiki-link 自动提取、反向链接索引、笔记变更时事件对账(reconciliation)
Tables backend/src/tables/ 多维表格 CRUD、jsonb 单元格更新(jsonb_set)、表格行/列操作
Explore 增强 backend/src/explore/ 新增 /api/v2/explore/stats 端点(笔记/目录/标签总数)、支持 folderId/tag 过滤

新增前端组件

组件 路径 功能
FolderTree frontend/src/components/tree/folder-tree.tsx 目录树:递归渲染、拖拽笔记移动、展开/折叠状态 localStorage 持久化、按名称排序(数字→字母→中文)
TagCloud frontend/src/components/tags/tag-cloud.tsx 标签云:三组分类(预设/使用中/未使用)、拖拽打标签、创建标签
SetTagsModal frontend/src/components/tags/set-tags-modal.tsx 批量为笔记设置标签
SearchDialog frontend/src/components/search/search-dialog.tsx 全局搜索对话框
BacklinksPanel frontend/src/components/editor/backlinks-panel.tsx 编辑器侧边反向链接面板

4.3 前端(frontend)

frontend/src/components/common/upload-image-mimetypes.ts

改动: 新增导出常量 acceptedAllFileTypes = '*/*',原 supportedMimeTypes/acceptedMimeTypes 保留不动。

目的: 为工具栏上传按钮提供「接受任意文件类型」的 accept 值。

frontend/src/components/editor-page/editor-pane/tool-bar/upload-image-button/upload-image-button.tsx

改动:

  • UploadInputallowedFileTypes 由原 acceptedMimeTypes 改为 acceptedAllFileTypes*/*)。
  • ToolbarButtoni18nKey'uploadImage' 改为 'uploadFile',按钮文案由「上传图片」变为「上传文件」。
  • cypressId 由 toolbar.uploadImage.* 改为 toolbar.uploadFile.*

目的: 让工具栏按钮可上传任意类型文件,且文案准确。

frontend/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx

改动:

  • 上传成功后,生成的链接使用绝对 URL${baseUrl}media/${uuid},而非原来的相对路径 /media/${uuid}
  • 非图片文件生成 [filename](url) 链接,图片生成 ![alt](url)
  • i18n key 由 uploadImage 系列改为 uploadFile 系列。
  • 引入 useBaseUrl() hook 获取站点根 URL。

目的: 绝对 URL 保证附件链接在笔记被移动、别名变化或跨域引用时仍可正确访问。

frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-entry.tsx

改动:

  • 媒体浏览器中,根据文件后缀判断是否为图片;非图片附件显示 IconFileText 文件图标(而非图片预览)。
  • 「插入到笔记」按钮对图片插入 ![alt](url),对非图片也用对应链接形式。
  • 预览链接 imageUrl 使用绝对 URL(useBaseUrl)。

目的: 让媒体浏览器正确区分图片与附件,非图片附件不再显示空白图。

frontend/locales/en.json

改动: 工具栏与上传提示文案的 i18n key 由 uploadImage 替换为 uploadFile

"toolbar": { "uploadFile": "Upload File" },
"editor": { "upload": { "uploadFile": { "withoutDescription": "Uploading file {{fileName}}", ... } } }

目的: 文案与「上传任意文件」的新行为一致。

frontend/cypress/e2e/fileUpload.spec.ts

改动: e2e 测试用例更新:cypressId 改为 toolbar.uploadFile / toolbar.uploadFile.input,断言的链接改为绝对 URL(http://127.0.0.1:3001/media/<uuid>),并补充 413(文件过大)失败用例。

目的: 保持测试与新行为一致。


5. vps1 部署配置

HedgeDoc 部署在 vps1,使用 Docker Compose。针对大文件上传与批量导入做了以下配置。

5.1 nginx

client_max_body_size 100m;

目的: 允许单个请求体最大 100MB,满足大附件(PDF、视频片段等)上传。默认 1MB 会触发 413。

5.2 docker-compose.yml 环境变量

environment:
  # 单文件上传上限:100MB(与 nginx 对齐)
  HD_MEDIA_MAX_UPLOAD_SIZE: "104857600"
  # 放开限流上限,避免批量导入触发 429
  HD_SECURITY_RATE_LIMIT_AUTH_MAX: "2000"
  HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_MAX: "2000"
  HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX: "2000"

说明:

  • HD_MEDIA_MAX_UPLOAD_SIZE:单位字节,104857600 = 100 × 1024 × 1024。
  • 三个 rate limit 上限提到 2000,配合 /media 路由豁免(见 4.1),保证批量导入 1500+ 笔记时不被限流。

5.3 镜像部署

vps1 无法直接 docker pull ghcr 镜像(CDN reset),改用 load_image.sh 通过 socks5h 代理手动下载镜像层后 docker load

bash load_image.sh backend
bash load_image.sh frontend

脚本细节见 3.2。加载后 docker images 应能看到 ghcr.io/maifeipin/hedgedoc/{backend,frontend}:develop


6. 导入流程

6.1 准备

  1. 在 vps 部署改造后的 HedgeDoc fork(见第 4、5 节)。
  2. load_image.sh 加载 backend / frontend 镜像并 docker compose up -d
  3. 在 vps1 本机执行 get_token.sh 获取 API token。

6.2 小批量试点

  1. youdao_md_full 中挑选一个覆盖不同附件类型(PDF、Excel、图片)的试点目录,例如 tools/youdao-import/pilot/
  2. 用 token 导入试点目录:
    python -u import_youdao_md.py pilot \
      --base-url http://localhost:3031 \
      --token <TOKEN>
    
  3. 验证:
    • 笔记正文是否完整、文件夹层级是否正确;
    • 图片是否正常显示;
    • 附件链接是否可点击下载(注意链接为绝对 URL)。

6.3 全量导入

试点通过后执行全量导入:

python -u import_youdao_md.py youdao_md_full \
  --base-url <URL> \
  --token <TOKEN>

脚本会在每 50 篇笔记时打印进度,结束时打印汇总统计与失败列表。

6.4 导入后处理失败笔记

脚本结束时输出的 Failures 列表即为需要后续处理的笔记。处理方式见第 7 节。


7. 已知问题和后续处理

7.1 PULL 缺失附件引用(17 个附件 / 15 篇笔记)

现象: youdaonote-pull 仅下载 Markdown 中被引用的附件,有 17 个附件引用对应的本地文件缺失(涉及 15 篇笔记)。

原因: 部分附件在有道侧未被正确关联,或拉取时下载失败。

处理: 从 APP 导出的 .note.attach/ 目录中补充。APP 导出包含每篇笔记的全部附件(纯原名),可手动将缺失附件上传到对应笔记,并修正 Markdown 链接。可借助 compare_attachments.py 定位具体缺失的笔记与文件名。

7.2 笔记内容过大触发 413(1 篇笔记)

现象: 单篇笔记在 create_note / update_note 时返回 413。

原因: 笔记内嵌了大量 base64 图片或超长正文,请求体超过 nginx client_max_body_size(100MB)或 HedgeDoc 笔记内容上限。

处理: 拆分该笔记,或将其中的内嵌图片改为先上传再引用。需单独手动处理。

7.3 文件名含非法字符(1 篇笔记)

现象: 笔记标题含 HedgeDoc 别名不允许的字符,导致 slugify 后别名异常或创建失败。

处理: 手动重命名源 Markdown 文件后再导入,或直接在 HedgeDoc 中手动创建并粘贴内容。


附录:工具文件清单

文件 用途
import_youdao_md.py 主导入脚本(笔记+图片+附件+文件夹结构)
load_image.sh ghcr 镜像手动下载并 docker load(绕过 CDN reset)
get_token.sh 在 vps1 localhost 获取 API token(绕过外部 IP 限流)
compare_exports.py PULL 与 APP 导出整体对齐分析(笔记/附件数量、命名对应)
compare_attachments.py 按笔记精确对比 PULL 与 APP 的附件文件差异
_list_failures.py 列出导入失败笔记(辅助后续处理)
pilot/ 小批量试点数据目录
youdao_md_full/ 全量 PULL 数据目录

image