Velog에서 Astro로 블로그 이전하기: 자동화 스크립트 작성기
#Astro
#Migration
#Node.js
#Velog
Velog로 Astro로 블로그를 이전하기로 결심했다. 글이 몇 개 없다면 수동으로 옮기겠지만, 효율적인 마이그레이션을 위해 Node.js 스크립트를 작성해 자동화한 과정을 공유한다.
1. Velog 글 백업하기
처음에는 npm 명령어로 백업 도구를 실행하려 했으나 404 에러가 발생했다. 확인해보니 대부분의 Velog 백업 툴은 npm 정식 등록보다는 GitHub 오픈소스로 관리되고 있었다.
나는 여러 도구 중 cjaewon/velog-backup 이라는 레포지토리를 사용해 성공적으로 백업을 완료했다. (좋은 코드 감사합니다!🙇)
이 레포를 사용하면 이미지와 포스팅이 각각 파일로 저장된다.
git clone https://github.com/cjaewon/velog-backup.git
cd velog-backup
npm install
node app.js -u 본인아이디
2. Astro 스키마에 맞춘 변환 스크립트 작성
다운로드 받은 마크다운 파일들을 Astro 프로젝트에 그대로 넣을 수는 없다. 내 블로그의 Content Collections 스키마가 정의되어 있는 것에 맞춰 Frontmatter 를 가공해야 한다.
이를 해결하기 위해 migrate.js 라는 변환 스크립트를 작성했다. 이 스크립트는 다음과 같은 역할을 수행한다.
- Velog 형식의 Frontmatter 를 Astro 스키마에 맞게 변경
- 구글 번역 API를 활용해 제목을 영문으로 번역하여 파일명(
YYYY-MM-DD-순번-slug.md)으로 자동 생성 - 본문 내의 이미지 경로를 Astro의 정적 폴더(
/images/posts/) 구조에 맞게 치환
3. 최종 마이그레이션 스크립트
완성된 migrate.js 의 전체 코드는 다음과 같다.
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import axios from 'axios';
const INPUT_DIR = './velog-posts';
const OUTPUT_DIR = './src/content/blog';
// 이미지 다운로드를 직접 하셨으므로, 이미지 폴더 생성 로직은 제외했습니다.
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
// 구글 번역 API를 활용한 영문 변환 및 슬러그화 함수
async function translateTitle(text) {
try {
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=ko&tl=en&dt=t&q=${encodeURIComponent(text)}`;
const res = await axios.get(url);
const translated = res.data[0][0][0];
// 소문자 변환, 알파벳/숫자 제외한 문자를 대시(-)로 치환, 연속된 대시 정리
return translated.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
} catch (e) {
console.warn(`⚠️ 제목 번역 실패 (${text}). 원본을 기반으로 임시 변환합니다.`);
return text.toLowerCase().replace(/[^a-z0-9가-힣]+/g, '-').replace(/(^-|-$)/g, '');
}
}
// Frontmatter 값 추출 헬퍼 함수
function getValue(raw, key) {
const reg = new RegExp(`${key}:\\s*(.*)`, 'i');
const match = raw.match(reg);
if (!match) return '';
return match[1].trim().replace(/^["']|["']$/g, '').replace(/["']/g, "'");
}
async function migrate() {
const files = fs.readdirSync(INPUT_DIR).filter(f => f.endsWith('.md'));
const dateCounter = {};
for (const file of files) {
try {
let rawContent = fs.readFileSync(path.join(INPUT_DIR, file), 'utf8');
const match = rawContent.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
if (!match) continue;
const frontmatterRaw = match[1];
const body = match[2];
const title = getValue(frontmatterRaw, 'title') || 'Untitled';
const description = getValue(frontmatterRaw, 'description');
const dateStr = getValue(frontmatterRaw, 'date');
const tagsMatch = frontmatterRaw.match(/tags:\s*\[(.*?)\]/);
const tags = tagsMatch ? tagsMatch[1].split(',').map(t => t.replace(/["'\s]/g, '')) : [];
// 날짜 파싱 및 YYYY-MM-DD 포맷팅
const pubDateObj = new Date(dateStr || new Date());
const yyyy = pubDateObj.getFullYear();
const mm = String(pubDateObj.getMonth() + 1).padStart(2, '0');
const dd = String(pubDateObj.getDate()).padStart(2, '0');
const formattedDate = `${yyyy}-${mm}-${dd}`;
// 동일 날짜의 순번 매기기
if (!dateCounter[formattedDate]) {
dateCounter[formattedDate] = 1;
} else {
dateCounter[formattedDate]++;
}
const sequence = dateCounter[formattedDate];
// 제목 번역 및 변환
const translatedSlug = await translateTitle(title);
const newFileName = `${formattedDate}-${sequence}-${translatedSlug}.md`;
const newFrontmatter = {
title: title,
description: description || '',
pubDate: pubDateObj,
tags: tags,
category: 'Devlog',
showAiBadge: false,
};
let newBody = body;
// 1. velog-backup 도구가 마크다운에 작성해둔 /images/ 경로를 /images/posts/로 치환
newBody = newBody.replace(/\]\(\/?images\/([^)]+)\)/g, '](/images/posts/$1)');
newBody = newBody.replace(/src=["']\/?images\/([^"']+)["']/g, 'src="/images/posts/$1"');
// 2. 혹시라도 변환되지 않고 남아있는 Velog 원본 URL이 있다면 대응
const velogImgRegex = /https:\/\/velog\.velcdn\.com\/images\/[^\s)"]+/g;
const velogMatches = newBody.match(velogImgRegex) || [];
for (const url of velogMatches) {
const fileName = url.split('/').pop();
newBody = newBody.replace(url, `/images/posts/${fileName}`);
}
const newContent = `---\n${yaml.dump(newFrontmatter)}---\n${newBody}`;
fs.writeFileSync(path.join(OUTPUT_DIR, newFileName), newContent);
console.log(`✅ 파일 생성 완료: ${newFileName}`);
} catch (error) {
console.error(`❌ 파일 처리 중 오류 발생 (${file}):`, error.message);
}
}
}
migrate();
4. 보너스: 카테고리 일괄 변경 스크립트
성공적으로 이전한 뒤, 특정 폴더 내의 마크다운 파일 카테고리를 일괄적으로 다른 카테고리로(예: React-book) 변경해야 할 일이 생겼다. 하나씩 수정하기 번거로워 이 역시 스크립트로 깔끔하게 처리했다.
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
const TARGET_DIR = './src/content/blog';
async function updateCategory() {
const files = fs.readdirSync(TARGET_DIR).filter(f => f.endsWith('.md'));
for (const file of files) {
const filePath = path.join(TARGET_DIR, file);
const rawContent = fs.readFileSync(filePath, 'utf8');
const match = rawContent.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
if (!match) continue;
const frontmatterRaw = match[1];
const body = match[2];
const frontmatter = yaml.load(frontmatterRaw);
frontmatter.category = 'React-book';
const newContent = `---\n${yaml.dump(frontmatter)}---\n${body}`;
fs.writeFileSync(filePath, newContent);
}
console.log(`🎉 카테고리 일괄 변경 완료`);
}
updateCategory();
자동화 스크립트 덕분에 수많은 글을 손쉽게 Astro 로 옮길 수 있었다.
이 포스팅은 AI의 도움을 받아 초안을 작성하고, 직접 검수 및 편집한 글입니다.