2060 words
10 minutes
迁移日记
2025-11-07

起始#

好久没更新,看了下,感觉该更新技术了

Vibe coding 启动#

让我看看你的实力

迁移概述#

将旧博客(Kilien.github.io)的所有文章迁移到新博客(new-blog)。

迁移日期: 2024-11-06
迁移状态: ✅ 成功完成

迁移统计#

项目数量
文章总数31 篇
成功迁移31 篇
失败迁移0 篇
迁移图片30+ 张
成功率100%

迁移的文章列表#

所有文章都已成功迁移到 src/content/posts/ 目录,每篇文章都有独立的文件夹。

包含图片的文章:

  • 3463483397d54c8486e83cefd36cfa0e (4 张图片)
  • 7820f28e7dfc440a9f4fc7f234c18cd6 (6 张图片)
  • 97b3f9df0e4646da98b9b5a2a070aae4 (2 张图片)
  • 9aad868cda6a4d808e958e00a9730d7b (1 张图片)
  • a22b65c4a625472abbb1660131d6f0f9 (5 张图片)
  • aa267042e0f642ca953d2456e00e3bfa (2 张图片)
  • e1e9a49cef81426b93cd94ed2e629e73 (5 张图片)
  • f8ae6f63d6bd4c55bdd6989335ad2375 (1 张图片)

Frontmatter 转换详情#

转换规则#

旧字段新字段转换说明
titletitle直接保留
updatedpublished使用更新日期作为发布日期
datepublished如无 updated 则使用 date
headerImageimage封面图片 URL
tags: [...]tags: ["..."]YAML 数组 → JSON 数组
-description自动从正文提取
-category使用第一个 tag
-draft默认 false

示例对比#

旧格式:

---
layout: Post
author: Kilien
title: LeetCode-66 加一
date: 2020-11-01
updated: 2022-09-13
headerImage: https://picsum.photos/seed/xxx/1920/1080
tags:
- leetcode
- 算法
---

新格式:

---
title: LeetCode-66 加一
published: 2022-09-13
description: "给定一个由整数组成的非空数组所表示的非负整数,在该数的基础上加一。"
image: "https://picsum.photos/seed/xxx/1920/1080"
tags: ["leetcode", "算法"]
category: "leetcode"
draft: false
---

图片处理#

处理方式#

  1. 自动识别文章中的本地图片引用(如 ../resources/xxx.png
  2. blog/resources/ 目录复制图片到文章目录
  3. 更新图片路径为相对路径(如 ./xxx.png

图片路径转换示例#

旧路径:

![图片](../resources/4e4843221b4848fc88aa96654f62a18a.png)

新路径:

![图片](./4e4843221b4848fc88aa96654f62a18a.png)

文件结构#

迁移前(旧博客)#

Kilien.github.io/
└── blog/
├── p/
│ ├── 3d1b156ab8ef46629863bc019e29160b.md
│ ├── 3a9cea8df5b14702b262fd7ce3b36311.md
│ └── ...
└── resources/
├── 2f92146e10794bba9def6195010e25f8.png
└── ...

迁移后(新博客)#

new-blog/
└── src/content/posts/
├── 3d1b156ab8ef46629863bc019e29160b/
│ └── index.md
├── 3a9cea8df5b14702b262fd7ce3b36311/
│ └── index.md
├── 7820f28e7dfc440a9f4fc7f234c18cd6/
│ ├── index.md
│ ├── 4e4843221b4848fc88aa96654f62a18a.png
│ └── ...
└── ...

迁移脚本功能#

核心功能#

  1. ✅ Frontmatter 解析与转换
  2. ✅ 标签数组格式转换(YAML → JSON)
  3. ✅ 自动提取文章描述
  4. ✅ 本地图片识别与复制
  5. ✅ 图片路径自动更新
  6. ✅ 文章独立目录组织
  7. ✅ 错误处理与日志输出

脚本特点#

  • 纯 Node.js 实现,无额外依赖
  • 支持批量迁移
  • 详细的进度显示
  • 失败容错机制
  • 完整的统计报告

使用的工具和技术#

  • 开发语言: Node.js (ES Modules)
  • 核心模块: fs, path
  • 脚本位置: scripts/migrate-from-old-blog.js
  • 执行命令: pnpm run migrate

后续建议#

必做事项#

  1. ✅ 验证所有文章在新博客中正常显示
  2. ✅ 检查图片是否正确加载
  3. ✅ 确认 frontmatter 格式正确

技术文档#

迁移脚本参考:

/* 迁移脚本:从旧博客迁移文章到新博客 */
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 配置路径
const OLD_BLOG_ROOT = path.resolve(__dirname, "../../Kilien.github.io")
const NEW_BLOG_ROOT = path.resolve(__dirname, "..")
const OLD_POSTS_DIR = path.join(OLD_BLOG_ROOT, "blog/p")
const OLD_RESOURCES_DIR = path.join(OLD_BLOG_ROOT, "blog/resources")
const NEW_POSTS_DIR = path.join(NEW_BLOG_ROOT, "src/content/posts")
// 验证路径是否存在
function validatePaths() {
if (!fs.existsSync(OLD_POSTS_DIR)) {
console.error(`错误: 旧博客文章目录不存在: ${OLD_POSTS_DIR}`)
process.exit(1)
}
if (!fs.existsSync(NEW_POSTS_DIR)) {
console.error(`错误: 新博客文章目录不存在: ${NEW_POSTS_DIR}`)
process.exit(1)
}
console.log("✓ 路径验证通过")
}
// 解析 frontmatter
function parseFrontmatter(content) {
const frontmatterRegex = /^---\n([\s\S]*?)\n---/
const match = content.match(frontmatterRegex)
if (!match) {
return { frontmatter: {}, body: content }
}
const frontmatterText = match[1]
const body = content.slice(match[0].length).trim()
const frontmatter = {}
const lines = frontmatterText.split("\n")
let currentKey = null
let currentArray = []
for (const line of lines) {
const trimmedLine = line.trim()
// 处理数组项
if (trimmedLine.startsWith("- ") && currentKey) {
currentArray.push(trimmedLine.slice(2).trim())
continue
}
// 如果之前在处理数组,现在遇到新的键
if (currentKey && currentArray.length > 0) {
frontmatter[currentKey] = currentArray
currentArray = []
currentKey = null
}
// 解析键值对
const colonIndex = trimmedLine.indexOf(":")
if (colonIndex > 0) {
const key = trimmedLine.slice(0, colonIndex).trim()
const value = trimmedLine.slice(colonIndex + 1).trim()
if (value === "") {
// 可能是数组的开始
currentKey = key
currentArray = []
} else {
frontmatter[key] = value
}
}
}
// 处理最后一个数组
if (currentKey && currentArray.length > 0) {
frontmatter[currentKey] = currentArray
}
return { frontmatter, body }
}
// 转换 frontmatter 格式
function transformFrontmatter(oldFrontmatter, body) {
const newFrontmatter = {
title: oldFrontmatter.title || "Untitled",
published: oldFrontmatter.updated || oldFrontmatter.date || new Date().toISOString().split("T")[0],
description: "",
image: "",
tags: [],
category: "",
draft: false
}
// 提取描述(从正文第一个有效段落)
// 跳过标题、列表、代码块等,寻找真正的文本段落
const lines = body.split('\n')
let description = ''
for (let i = 0; i < lines.length && !description; i++) {
const line = lines[i].trim()
// 跳过空行、标题、列表、代码块、引用等
if (line &&
!line.startsWith('#') &&
!line.startsWith('*') &&
!line.startsWith('-') &&
!line.startsWith('>') &&
!line.startsWith('```') &&
!line.startsWith('!') &&
line.length > 20) {
description = line
break
}
}
if (description) {
// 移除markdown语法并限制长度
description = description
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // 移除链接
.replace(/[*_`]/g, '') // 移除格式化符号
.slice(0, 150)
newFrontmatter.description = description
}
// 处理 headerImage -> image
if (oldFrontmatter.headerImage) {
newFrontmatter.image = oldFrontmatter.headerImage
}
// 处理 tags (从数组转换)
if (oldFrontmatter.tags) {
if (Array.isArray(oldFrontmatter.tags)) {
newFrontmatter.tags = oldFrontmatter.tags
} else if (typeof oldFrontmatter.tags === "string") {
newFrontmatter.tags = [oldFrontmatter.tags]
}
}
// 从tags中提取第一个作为category(如果没有单独的category)
if (newFrontmatter.tags.length > 0) {
newFrontmatter.category = newFrontmatter.tags[0]
}
return newFrontmatter
}
// 查找文章中引用的本地图片
function findLocalImages(body) {
const images = []
// 匹配 markdown 图片语法: ![alt](path)
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
let match
while ((match = imageRegex.exec(body)) !== null) {
const imagePath = match[2]
// 检查是否是本地图片(不是http/https开头)
if (!imagePath.startsWith("http://") && !imagePath.startsWith("https://")) {
images.push({
original: match[0],
alt: match[1],
path: imagePath
})
}
}
return images
}
// 复制图片文件
function copyImage(imagePath, postDirPath) {
// 提取文件名
const fileName = path.basename(imagePath)
// 构建源路径 - 处理 ../resources/ 这样的相对路径
let sourcePath
if (imagePath.includes("../resources/") || imagePath.includes("..\\resources\\")) {
// 从resources目录查找
sourcePath = path.join(OLD_RESOURCES_DIR, fileName)
} else {
// 尝试在resources目录查找
sourcePath = path.join(OLD_RESOURCES_DIR, fileName)
// 如果不存在,尝试绝对路径或相对于OLD_POSTS_DIR的路径
if (!fs.existsSync(sourcePath)) {
sourcePath = path.join(OLD_POSTS_DIR, imagePath)
}
}
// 如果还是找不到
if (!fs.existsSync(sourcePath)) {
console.warn(` ⚠ 找不到图片: ${imagePath}`)
return null
}
// 目标路径
const targetPath = path.join(postDirPath, fileName)
try {
fs.copyFileSync(sourcePath, targetPath)
console.log(` ✓ 复制图片: ${fileName}`)
return fileName
} catch (error) {
console.warn(` ⚠ 复制图片失败: ${fileName} - ${error.message}`)
return null
}
}
// 更新文章中的图片路径
function updateImagePaths(body, images, postDirPath) {
let updatedBody = body
for (const image of images) {
const newFileName = copyImage(image.path, postDirPath)
if (newFileName) {
// 更新为相对路径
const newImageMd = `![${image.alt}](./${newFileName})`
updatedBody = updatedBody.replace(image.original, newImageMd)
}
}
return updatedBody
}
// 生成新的 frontmatter 字符串
function generateFrontmatter(frontmatter) {
const lines = ["---"]
lines.push(`title: ${frontmatter.title}`)
lines.push(`published: ${frontmatter.published}`)
lines.push(`description: '${frontmatter.description.replace(/'/g, "\\'")}'`)
lines.push(`image: '${frontmatter.image}'`)
// tags 使用 JSON 数组格式
const tagsStr = JSON.stringify(frontmatter.tags)
lines.push(`tags: ${tagsStr}`)
lines.push(`category: '${frontmatter.category}'`)
lines.push(`draft: ${frontmatter.draft}`)
lines.push("---")
return lines.join("\n")
}
// 迁移单个文件
function migratePost(fileName) {
console.log(`\n处理: ${fileName}`)
const oldFilePath = path.join(OLD_POSTS_DIR, fileName)
const content = fs.readFileSync(oldFilePath, "utf-8")
// 解析旧的 frontmatter
const { frontmatter: oldFrontmatter, body } = parseFrontmatter(content)
// 转换 frontmatter
const newFrontmatter = transformFrontmatter(oldFrontmatter, body)
// 创建文章目录(使用文章的ID或标题)
const postId = path.basename(fileName, ".md")
const postDirPath = path.join(NEW_POSTS_DIR, postId)
if (!fs.existsSync(postDirPath)) {
fs.mkdirSync(postDirPath, { recursive: true })
}
// 查找并处理图片
const localImages = findLocalImages(body)
let updatedBody = body
if (localImages.length > 0) {
console.log(` 发现 ${localImages.length} 个本地图片`)
updatedBody = updateImagePaths(body, localImages, postDirPath)
}
// 生成新文件内容
const newContent = generateFrontmatter(newFrontmatter) + "\n\n" + updatedBody
// 写入新文件
const newFilePath = path.join(postDirPath, "index.md")
fs.writeFileSync(newFilePath, newContent, "utf-8")
console.log(` ✓ 迁移完成: ${postId}/index.md`)
return { success: true, fileName, postId }
}
// 主函数
function main() {
console.log("=" .repeat(60))
console.log("开始迁移文章从旧博客到新博客")
console.log("=" .repeat(60))
validatePaths()
// 读取所有markdown文件
const files = fs.readdirSync(OLD_POSTS_DIR).filter(f => f.endsWith(".md"))
console.log(`\n找到 ${files.length} 篇文章待迁移`)
const results = {
success: [],
failed: []
}
// 迁移每篇文章
for (const fileName of files) {
try {
const result = migratePost(fileName)
results.success.push(result)
} catch (error) {
console.error(` ✗ 迁移失败: ${fileName}`)
console.error(` 错误: ${error.message}`)
results.failed.push({ fileName, error: error.message })
}
}
// 输出统计
console.log("\n" + "=" .repeat(60))
console.log("迁移完成统计")
console.log("=" .repeat(60))
console.log(`成功: ${results.success.length} 篇`)
console.log(`失败: ${results.failed.length} 篇`)
if (results.failed.length > 0) {
console.log("\n失败的文章:")
results.failed.forEach(({ fileName, error }) => {
console.log(` - ${fileName}: ${error}`)
})
}
console.log("\n✓ 迁移流程结束")
}
// 运行
main()

注意事项#

  1. 备份建议: 迁移前已建议备份数据
  2. 路径依赖: 脚本假设两个项目在同一父目录
  3. 覆盖行为: 已存在的文章会被覆盖
  4. 图片处理: 外链图片保持不变,仅处理本地图片

结论#

迁移成功完成!

所有 31 篇文章及相关图片资源已成功从旧博客迁移到新博客。Frontmatter 格式已正确转换,图片路径已更新为相对路径。新博客已准备好进行本地预览和部署。

不愧是我的惊人智慧[拍胸][拍胸]

迁移日记
https://evanyuki.github.io/posts/d54a2d69464a4bb1b4f6b3890fd37ba3/
Author
Evanyuki
Published at
2025-11-07
License
CC BY-NC-SA 4.0