WLOP | 北漠
2060 words
10 minutes
迁移日记
起始
好久没更新,看了下,感觉该更新技术了
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 转换详情
转换规则
| 旧字段 | 新字段 | 转换说明 |
|---|---|---|
title | title | 直接保留 |
updated | published | 使用更新日期作为发布日期 |
date | published | 如无 updated 则使用 date |
headerImage | image | 封面图片 URL |
tags: [...] | tags: ["..."] | YAML 数组 → JSON 数组 |
| - | description | 自动从正文提取 |
| - | category | 使用第一个 tag |
| - | draft | 默认 false |
示例对比
旧格式:
---layout: Postauthor: Kilientitle: LeetCode-66 加一date: 2020-11-01updated: 2022-09-13headerImage: https://picsum.photos/seed/xxx/1920/1080tags: - leetcode - 算法---新格式:
---title: LeetCode-66 加一published: 2022-09-13description: "给定一个由整数组成的非空数组所表示的非负整数,在该数的基础上加一。"image: "https://picsum.photos/seed/xxx/1920/1080"tags: ["leetcode", "算法"]category: "leetcode"draft: false---图片处理
处理方式
- 自动识别文章中的本地图片引用(如
../resources/xxx.png) - 从
blog/resources/目录复制图片到文章目录 - 更新图片路径为相对路径(如
./xxx.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 │ └── ... └── ...迁移脚本功能
核心功能
- ✅ Frontmatter 解析与转换
- ✅ 标签数组格式转换(YAML → JSON)
- ✅ 自动提取文章描述
- ✅ 本地图片识别与复制
- ✅ 图片路径自动更新
- ✅ 文章独立目录组织
- ✅ 错误处理与日志输出
脚本特点
- 纯 Node.js 实现,无额外依赖
- 支持批量迁移
- 详细的进度显示
- 失败容错机制
- 完整的统计报告
使用的工具和技术
- 开发语言: Node.js (ES Modules)
- 核心模块: fs, path
- 脚本位置:
scripts/migrate-from-old-blog.js - 执行命令:
pnpm run migrate
后续建议
必做事项
- ✅ 验证所有文章在新博客中正常显示
- ✅ 检查图片是否正确加载
- ✅ 确认 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("✓ 路径验证通过")}
// 解析 frontmatterfunction 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 图片语法:  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 = `` 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()注意事项
- 备份建议: 迁移前已建议备份数据
- 路径依赖: 脚本假设两个项目在同一父目录
- 覆盖行为: 已存在的文章会被覆盖
- 图片处理: 外链图片保持不变,仅处理本地图片
结论
✅ 迁移成功完成!
所有 31 篇文章及相关图片资源已成功从旧博客迁移到新博客。Frontmatter 格式已正确转换,图片路径已更新为相对路径。新博客已准备好进行本地预览和部署。
不愧是我的惊人智慧[拍胸][拍胸]