Ghost Admin API로 블로그 글 일괄 관리하기: 예약 발행 스케줄 자동 조정

Ghost Admin에서 글 하나씩 날짜 바꾸기 지치셨나요? API 스크립트 한 번으로 예약글 스케줄을 일괄 조정하는 방법을 알려드립니다.

Ghost Admin API로 블로그 글 일괄 관리하기: 예약 발행 스케줄 자동 조정

1. 문제 상황

블로그 글이 쌓이면 생기는 문제

예약 발행 기능으로 블로그 글을 미리 작성해두면 편합니다. 그런데 글이 10개, 20개 쌓이면 새로운 문제가 생깁니다.

현재 예약된 글:
1/28 - 플로팅 목차 만들기
1/29 - Ubuntu 서버 보안
1/30 - Ghost 수익화
2/02 - Ghost 예약 발행
...

여기서 새 글을 1/28에 끼워넣고 싶다면?

Ghost Admin에서 하나씩 날짜를 바꿔야 합니다. 글이 12개면 12번 클릭. 실수로 같은 날짜에 두 글을 예약하면 충돌.

추가로 하고 싶은 것들

  1. 예약된 글 전체 목록 확인 - 한눈에 스케줄 파악
  2. 말투 일관성 검수 - 존댓말/반말 혼용 검사
  3. 스케줄 일괄 조정 - 새 글 삽입 시 나머지 하루씩 밀기
  4. 태그 현황 파악 - 어떤 태그가 몇 개 글에 사용됐는지

Ghost Admin UI로는 하나씩 수동으로 해야 합니다. API를 쓰면 스크립트 한 번 실행으로 끝.


2. 원인 분석 (Ghost Admin API 이해하기)

Ghost API 종류

Ghost는 두 가지 API를 제공합니다.

API 용도 인증
Content API 공개 콘텐츠 읽기 (블로그 프론트엔드용) API Key
Admin API 콘텐츠 관리 (CRUD, 설정 변경) JWT

Content API는 발행된 글만 읽을 수 있습니다. 예약글, 초안 관리는 Admin API가 필요합니다.

Admin API 인증 방식

Admin API는 JWT(JSON Web Token) 인증을 사용합니다. API Key를 그대로 보내는 게 아니라, 서명된 토큰을 생성해서 보내야 합니다.

API Key 형식:
{id}:{secret}

이 키로 JWT를 생성해서 Authorization: Ghost {token} 헤더에 담아 보냅니다.

API Key 발급 위치

Ghost Admin → Settings → Integrations → + Add custom integration

Integration을 만들면 Admin API Key가 생성됩니다. Content API Key도 같이 생성되는데, 이건 공개 콘텐츠 읽기용입니다.


3. 해결 방법

Step 1: JWT 토큰 생성

Ghost Admin API 호출에 필요한 JWT 토큰 생성 코드입니다.

const crypto = require('crypto');

// API Key (Ghost Admin → Settings → Integrations에서 확인)
const API_KEY = '{your-api-key}';  // {id}:{secret} 형식
const [id, secret] = API_KEY.split(':');

function generateToken() {
  // JWT 헤더
  const header = {
    alg: 'HS256',  // 알고리즘
    typ: 'JWT',
    kid: id        // ← API Key의 id 부분
  };

  // JWT 페이로드
  const now = Math.floor(Date.now() / 1000);
  const payload = {
    iat: now,           // 발급 시간
    exp: now + 300,     // 만료 시간 (5분)
    aud: '/admin/'      // 대상 (Admin API)
  };

  // Base64URL 인코딩 함수
  const base64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');

  const headerB64 = base64url(header);
  const payloadB64 = base64url(payload);

  // HMAC-SHA256 서명 (secret은 hex 디코딩 필요)
  const hmac = crypto.createHmac('sha256', Buffer.from(secret, 'hex'));
  hmac.update(headerB64 + '.' + payloadB64);
  const signature = hmac.digest('base64url');

  return headerB64 + '.' + payloadB64 + '.' + signature;
}

const token = generateToken();
console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjY5NzQ2YmI1Yzc0MTYxMDAwMWE4ZGFkNCJ9...

핵심 포인트:

  • kid 필드에 API Key의 id 부분을 넣어야 합니다
  • secret은 hex 문자열이므로 Buffer.from(secret, 'hex')로 디코딩
  • 토큰 유효시간은 5분이면 충분합니다

Step 2: API 요청 함수 만들기

토큰 생성과 HTTP 요청을 묶은 재사용 가능한 함수입니다.

const https = require('https');

const GHOST_URL = 'your-blog.ghost.io';  // 본인 블로그 URL

function apiRequest(method, path, body = null) {
  return new Promise((resolve, reject) => {
    const token = generateToken();  // 매 요청마다 새 토큰 생성

    const options = {
      hostname: GHOST_URL,
      path: '/ghost/api/admin' + path,
      method,
      headers: {
        'Authorization': 'Ghost ' + token,  // ← "Ghost " 접두사 필수
        'Content-Type': 'application/json'
      }
    };

    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try {
          resolve(JSON.parse(data));
        } catch (e) {
          resolve(data);
        }
      });
    });

    req.on('error', reject);
    if (body) req.write(JSON.stringify(body));
    req.end();
  });
}

주의: Authorization 헤더는 Bearer가 아니라 Ghost입니다.

✗ Authorization: Bearer eyJhbG...
✓ Authorization: Ghost eyJhbG...

Step 3: 예약된 글 목록 조회

async function getScheduledPosts() {
  const result = await apiRequest('GET',
    '/posts/?status=scheduled&limit=all&fields=id,title,published_at,updated_at,slug'
  );

  // 발행일 기준 정렬
  const posts = result.posts.sort((a, b) =>
    new Date(a.published_at) - new Date(b.published_at)
  );

  console.log('예약된 글 (' + posts.length + '개):');
  posts.forEach(p => {
    const d = new Date(p.published_at);
    const weekday = ['일', '월', '화', '수', '목', '금', '토'][d.getUTCDay()];
    console.log('  ' + d.toISOString().slice(0,10) + ' (' + weekday + ') - ' + p.title);
  });

  return posts;
}

쿼리 파라미터 설명:

파라미터 설명 예시
status 글 상태 필터 published, scheduled, draft
limit 결과 수 all, 10, 50
fields 반환할 필드 (쉼표 구분) id,title,published_at
formats 콘텐츠 포맷 mobiledoc, html, plaintext
include 관계 데이터 포함 tags, authors

출력 예시:

예약된 글 (12개):
  2026-01-27 (화) - Ghost 블로그 Analytics 설정 완벽 가이드
  2026-01-28 (수) - Tailscale + SSH로 아이폰에서 맥 원격 접속하기
  2026-01-29 (목) - Ghost 블로그에 플로팅 목차(TOC) 만들기
  2026-01-30 (금) - Ubuntu 서버 보안 강화 완전 가이드
  2026-02-02 (월) - 한국에서 Ghost 블로그 수익화하기
  ...

Step 4: 태그 목록 조회

async function getTags() {
  const result = await apiRequest('GET', '/tags/?limit=all');

  console.log('태그 목록:');
  result.tags.forEach(t => {
    console.log('  - ' + t.name + ' (slug: ' + t.slug + ', id: ' + t.id + ')');
  });

  return result.tags;
}

태그 ID는 글에 태그를 지정할 때 필요합니다.

Step 5: 글 콘텐츠 가져오기 (말투 검수용)

Ghost는 콘텐츠를 Mobiledoc 포맷으로 저장합니다. 마크다운 블록에서 텍스트를 추출해야 합니다.

async function getPostContent(postId) {
  const result = await apiRequest('GET', '/posts/' + postId + '/?formats=mobiledoc');
  const post = result.posts[0];

  // Mobiledoc에서 마크다운 텍스트 추출
  const mobiledoc = JSON.parse(post.mobiledoc);
  let content = '';

  for (const card of (mobiledoc.cards || [])) {
    if (card[0] === 'markdown') {
      content += card[1].markdown || '';
    }
  }

  return content;
}

Mobiledoc 구조:

{
  "version": "0.3.1",
  "atoms": [],
  "cards": [
    ["markdown", { "markdown": "# 제목\n\n본문 내용..." }],
    ["image", { "src": "https://...", "alt": "이미지 설명" }]
  ],
  "markups": [],
  "sections": [[10, 0], [10, 1]]
}

카드 타입:

  • markdown: 마크다운 블록
  • image: 이미지
  • code: 코드 블록
  • html: HTML 블록
  • embed: 임베드 (YouTube, Twitter 등)

Step 6: 말투 일관성 검사

존댓말/반말 혼용을 검사하는 함수입니다.

async function checkTone(posts) {
  // 반말 패턴 (문장 끝)
  const casualPatterns = [
    { pattern: /있다\./g, fix: '있습니다.' },
    { pattern: /없다\./g, fix: '없습니다.' },
    { pattern: /된다\./g, fix: '됩니다.' },
    { pattern: /한다\./g, fix: '합니다.' },
    { pattern: /이다\./g, fix: '입니다.' },
    { pattern: /하자\./g, fix: '합시다.' },
    { pattern: /보자\./g, fix: '봅시다.' },
  ];

  for (const post of posts) {
    const content = await getPostContent(post.id);
    const issues = [];

    // 코드 블록 제외한 텍스트만 검사
    const textOnly = content.replace(/```[\s\S]*?```/g, '');

    casualPatterns.forEach(({ pattern, fix }) => {
      const matches = textOnly.match(pattern);
      if (matches) {
        issues.push({
          found: matches[0],
          suggestion: fix,
          count: matches.length
        });
      }
    });

    if (issues.length > 0) {
      console.log('\n[반말 발견] ' + post.title);
      issues.forEach(i => {
        console.log('  ' + i.found + ' → ' + i.suggestion + ' (' + i.count + '회)');
      });
    }
  }
}

출력 예시:

[반말 발견] Tailscale + SSH로 아이폰에서 맥 원격 접속하기
  있다. → 있습니다. (3회)
  한다. → 합니다. (2회)
  이다. → 입니다. (1회)

Step 7: 스케줄 일괄 조정 (하루씩 밀기)

새 글을 특정 날짜에 끼워넣고, 그 이후 글들을 하루씩 미루는 함수입니다.

async function adjustSchedule(fromDate) {
  const posts = await getScheduledPosts();

  // fromDate 이후 글만 필터
  const toAdjust = posts.filter(p =>
    new Date(p.published_at) >= new Date(fromDate)
  );

  // 역순으로 조정 (날짜 충돌 방지)
  toAdjust.reverse();

  console.log('\n스케줄 조정 (' + toAdjust.length + '개):');

  for (const post of toAdjust) {
    const oldDate = new Date(post.published_at);
    let newDate = new Date(oldDate);
    newDate.setDate(newDate.getDate() + 1);  // 하루 뒤로

    // 주말이면 월요일로 (선택사항)
    if (newDate.getDay() === 0) newDate.setDate(newDate.getDate() + 1);  // 일→월
    if (newDate.getDay() === 6) newDate.setDate(newDate.getDate() + 2);  // 토→월

    console.log('  ' + oldDate.toISOString().slice(0,10) +
                ' → ' + newDate.toISOString().slice(0,10) +
                ' : ' + post.title.slice(0,30));

    // API로 업데이트
    const result = await apiRequest('PUT', '/posts/' + post.id + '/', {
      posts: [{
        published_at: newDate.toISOString(),
        updated_at: post.updated_at  // ← 필수! 충돌 방지용
      }]
    });

    if (result.errors) {
      console.log('    ERROR: ' + result.errors[0].message);
    } else {
      console.log('    OK');
    }
  }
}

핵심 포인트:

  1. 역순 처리: 2/11 → 2/10 → 2/09 순서로 밀어야 날짜 충돌이 안 생깁니다
  2. updated_at 필수: 글 수정 시 현재 updated_at 값을 함께 보내야 합니다. Ghost가 동시 수정 충돌을 방지하는 방식입니다
  3. 주말 건너뛰기: 평일만 발행하려면 토/일 체크 로직 추가

Step 8: 새 글 예약 발행

초안 상태의 글을 특정 날짜에 예약 발행하는 함수입니다.

async function schedulePost(postId, publishDate, tagIds = []) {
  // 현재 글 정보 가져오기 (updated_at 필요)
  const current = await apiRequest('GET', '/posts/' + postId + '/');
  const post = current.posts[0];

  console.log('예약 발행 설정:');
  console.log('  제목: ' + post.title);
  console.log('  현재 상태: ' + post.status);
  console.log('  예약일: ' + publishDate);

  // 예약 발행으로 변경
  const result = await apiRequest('PUT', '/posts/' + postId + '/', {
    posts: [{
      status: 'scheduled',
      published_at: new Date(publishDate).toISOString(),
      updated_at: post.updated_at,
      tags: tagIds.map(id => ({ id }))  // 태그 지정 (선택)
    }]
  });

  if (result.errors) {
    console.log('ERROR: ' + result.errors[0].message);
  } else {
    console.log('OK - 예약 완료');
  }
}

// 사용 예시
schedulePost(
  '697778da9f8fa30001d1a3dc',           // 글 ID
  '2026-01-28T00:00:58.000Z',            // 발행일 (UTC)
  ['69761ecdc741610001a8db18']           // 태그 ID (인프라)
);

Step 9: 전체 워크플로우 스크립트

위 함수들을 조합한 전체 스크립트입니다.

const crypto = require('crypto');
const https = require('https');

// === 설정 ===
const API_KEY = '{your-api-key}';  // {id}:{secret} 형식
const GHOST_URL = 'your-blog.ghost.io';  // 본인 블로그 URL
const [id, secret] = API_KEY.split(':');

// === JWT 토큰 생성 ===
function generateToken() {
  const header = { alg: 'HS256', typ: 'JWT', kid: id };
  const now = Math.floor(Date.now() / 1000);
  const payload = { iat: now, exp: now + 300, aud: '/admin/' };
  const base64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
  const headerB64 = base64url(header);
  const payloadB64 = base64url(payload);
  const hmac = crypto.createHmac('sha256', Buffer.from(secret, 'hex'));
  hmac.update(headerB64 + '.' + payloadB64);
  return headerB64 + '.' + payloadB64 + '.' + hmac.digest('base64url');
}

// === API 요청 ===
function apiRequest(method, path, body = null) {
  return new Promise((resolve, reject) => {
    const token = generateToken();
    const options = {
      hostname: GHOST_URL,
      path: '/ghost/api/admin' + path,
      method,
      headers: {
        'Authorization': 'Ghost ' + token,
        'Content-Type': 'application/json'
      }
    };
    const req = https.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => resolve(JSON.parse(data)));
    });
    req.on('error', reject);
    if (body) req.write(JSON.stringify(body));
    req.end();
  });
}

// === 메인 워크플로우 ===
async function main() {
  console.log('=== Ghost 블로그 관리 스크립트 ===\n');

  // 1. 현재 예약된 글 확인
  console.log('1. 예약된 글 목록');
  const posts = await getScheduledPosts();

  // 2. 태그 목록 확인
  console.log('\n2. 태그 목록');
  const tags = await getTags();

  // 3. 말투 검수 (선택)
  console.log('\n3. 말투 검수');
  await checkTone(posts);

  // 4. 스케줄 조정 (선택)
  // await adjustSchedule('2026-01-28');

  // 5. 새 글 예약 (선택)
  // await schedulePost('post-id', '2026-01-28T00:00:00Z', ['tag-id']);

  console.log('\n=== 완료 ===');
}

main().catch(console.error);

4. 핵심 개념 정리

Ghost Admin API 엔드포인트

엔드포인트 메서드 설명
/posts/ GET 글 목록 조회
/posts/{id}/ GET 글 상세 조회
/posts/{id}/ PUT 글 수정
/posts/ POST 글 생성
/posts/{id}/ DELETE 글 삭제
/tags/ GET 태그 목록
/members/ GET 멤버 목록
/images/upload/ POST 이미지 업로드

글 상태 (status)

상태 설명
draft 초안 (비공개)
scheduled 예약 발행
published 발행됨
sent 이메일 발송됨

자주 쓰는 쿼리 파라미터

# 예약글만, 모든 필드
/posts/?status=scheduled&limit=all

# 특정 필드만 (응답 크기 줄이기)
/posts/?fields=id,title,published_at,slug

# 태그, 작성자 정보 포함
/posts/?include=tags,authors

# 콘텐츠 포맷 지정
/posts/{id}/?formats=mobiledoc
/posts/{id}/?formats=html
/posts/{id}/?formats=plaintext

# 고급 필터
/posts/?filter=tag:ghost+published_at:>'2026-01-01'

JWT vs API Key 차이

항목 Content API Admin API
인증 방식 API Key 직접 전송 JWT 토큰
헤더 ?key={api_key} (쿼리) Authorization: Ghost {jwt}
권한 읽기 전용 전체 CRUD
대상 콘텐츠 공개 글만 모든 글 (초안, 예약 포함)

5. 베스트 프랙티스

API 사용 시 체크리스트

□ Integration 생성 후 Admin API Key 복사
□ JWT 토큰 만료 시간 적절히 설정 (5분 권장)
□ Authorization 헤더에 "Ghost " 접두사 확인
□ PUT 요청 시 updated_at 필드 포함
□ 날짜 조정은 역순으로 (충돌 방지)
□ 에러 응답 확인 후 재시도 로직 추가

보안 주의사항

□ API Key는 환경변수나 설정 파일로 분리
□ 코드에 API Key 하드코딩 금지
□ .gitignore에 설정 파일 추가
□ 프로덕션 블로그에 테스트 전 스테이징에서 먼저 확인

스케줄 조정 전략

  1. 역순 처리: 마지막 날짜부터 조정해야 충돌 없음
  2. 주말 건너뛰기: 평일 발행이면 토/일 → 월요일로
  3. 시간대 주의: Ghost는 UTC 기준, 한국은 KST(+9시간)
  4. 드라이런 먼저: 실제 업데이트 전 콘솔 출력으로 확인

6. FAQ

Q: API Key가 노출되면 어떻게 되나요?

A: Admin API Key가 노출되면 블로그 전체 콘텐츠에 대한 CRUD 권한이 탈취됩니다. 즉시 Ghost Admin → Settings → Integrations에서 해당 Integration을 삭제하고 새로 만드세요.

Q: JWT 토큰 유효시간은 얼마가 적당한가요?

A: 5분(300초)이면 충분합니다. 매 API 요청마다 새 토큰을 생성하므로 길게 잡을 필요가 없습니다. 최대 24시간까지 설정 가능하지만 보안상 짧게 유지하는 게 좋습니다.

Q: updated_at을 왜 보내야 하나요?

A: Ghost의 동시 수정 충돌 방지 메커니즘입니다. 다른 사람(또는 다른 스크립트)이 먼저 글을 수정했다면, 내가 가진 updated_at과 서버의 값이 다르므로 요청이 거부됩니다. 이를 통해 덮어쓰기 사고를 방지합니다.

Q: 예약 시간을 UTC가 아닌 KST로 지정할 수 있나요?

A: API는 UTC만 받습니다. 스크립트에서 KST → UTC 변환이 필요합니다.

// KST 오전 9시 → UTC 자정
const kst = new Date('2026-01-28T09:00:00+09:00');
const utc = kst.toISOString();  // 2026-01-28T00:00:00.000Z

Q: 발행된 글의 날짜도 바꿀 수 있나요?

A: 네, 가능합니다. 하지만 이미 발행된 글의 published_at을 바꾸면 RSS 피드나 검색엔진 인덱스에 혼란을 줄 수 있으니 주의하세요.


7. 참고 자료


8. 다음 단계

이번 시리즈에서는 n8n 기본 설정부터 Claude Code 연동, 그리고 API 직접 호출까지 다뤘습니다. 이제 블로그 운영의 대부분을 자동화할 수 있습니다.

시리즈 목차:

  1. n8n으로 블로그 자동화 구축하기: Ghost 연동부터 실전 워크플로우까지
  2. Claude Code Hooks로 블로그 글 자동 전송하기: n8n Webhook 연동
  3. Ghost Admin API로 블로그 글 일괄 관리하기 ← 현재 글