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의 도움을 받아 초안을 작성하고, 직접 검수 및 편집한 글입니다.