[Astro] 소셜 공유 자동화 스크립트

2026-04-04 hit count image

블로그 포스트를 Facebook, LinkedIn, Threads, Bluesky, Mastodon 등 소셜 미디어에 자동으로 공유하는 스크립트를 구현한 방법을 공유합니다.

astro

개요

이전 글 GitHub Pages 배포에서 배포 과정을 다루었습니다. 이번 글에서는 블로그 포스트를 다양한 소셜 미디어 플랫폼에 자동으로 공유하는 스크립트 시스템을 설명합니다.

블로그에 새 글을 올리고 나면 Facebook, LinkedIn, Threads, Bluesky, Mastodon 등 여러 SNS에 하나씩 수동으로 공유해야 하는데, 이 작업이 생각보다 번거롭습니다. 특히 다국어 블로그는 언어별로 공유해야 하니 횟수가 더 늘어납니다. 이런 반복 작업을 자동화하기 위해 RSS 피드를 기반으로 새 포스트를 감지하고, 여러 플랫폼에 한 번에 공유하는 Node.js 스크립트를 개발했습니다.

이 글에서는 실제 사용 중인 코드를 모두 공개하며, 각 모듈의 역할과 구현을 상세히 설명합니다.

스크립트 구조

소셜 공유 시스템은 scripts/ 디렉토리에 모듈별로 분리되어 있습니다.

scripts/
├── share.mjs                 # 메인 진입점 (CLI)
├── share-status.json         # 공유 상태 추적 파일
└── lib/
    ├── rss-reader.mjs        # RSS 피드 파싱
    ├── status-manager.mjs    # 공유 상태 관리
    ├── formatter.mjs         # 플랫폼별 메시지 포맷터
    ├── logger.mjs            # 컬러 로거
    └── platforms/
        ├── facebook.mjs      # Facebook Graph API
        ├── linkedin.mjs      # LinkedIn API
        ├── threads.mjs       # Threads API
        ├── bluesky.mjs       # Bluesky AT Protocol API
        └── mastodon.mjs      # Mastodon API

전체 흐름은 다음과 같습니다.

  1. rss-reader.mjs가 빌드된 RSS 피드에서 포스트 목록을 추출
  2. status-manager.mjsshare-status.json과 비교하여 새 포스트를 등록
  3. share.mjs가 미공유 포스트를 찾아 각 플랫폼 모듈의 post() 함수를 호출
  4. 각 플랫폼 모듈이 formatter.mjs로 메시지를 포맷팅한 뒤 API를 호출

사용 가능한 npm 스크립트

package.json에 등록된 공유 관련 스크립트들입니다.

{
  "scripts": {
    "share:sync": "node scripts/share.mjs sync",
    "share:status": "node scripts/share.mjs status",
    "share:status:latest": "node scripts/share.mjs status --pending --latest 3",
    "share:status:oldest": "node scripts/share.mjs status --pending --oldest 3",
    "share": "node scripts/share.mjs post",
    "share:oldest": "node scripts/share.mjs post --oldest",
    "share:latest": "node scripts/share.mjs post --latest"
  }
}

RSS 피드 파싱 — rss-reader.mjs

가장 먼저 빌드된 RSS 피드에서 포스트 정보를 추출하는 모듈입니다. 언어별 RSS 피드(feed.xml, ko/feed.xml, en/feed.xml)를 읽어 포스트를 permalink 기준으로 그룹핑합니다. 하나의 포스트가 3개 언어 버전을 가질 수 있으므로, versions 객체에 언어별 제목/URL/설명을 저장합니다.

// scripts/lib/rss-reader.mjs
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';

const SITE_URL = 'https://deku.posstree.com';

const FEEDS = [
  { lang: 'ja', path: 'dist/feed.xml' },
  { lang: 'ko', path: 'dist/ko/feed.xml' },
  { lang: 'en', path: 'dist/en/feed.xml' },
];

function decodeXmlEntities(text) {
  return text
    .replace(/&/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'");
}

function parseItems(xml) {
  const items = [];
  const itemRegex = /<item>([\s\S]*?)<\/item>/g;
  let match;

  while ((match = itemRegex.exec(xml)) !== null) {
    const content = match[1];
    const get = (tag) => {
      const m = content.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
      return m ? decodeXmlEntities(m[1].trim()) : '';
    };

    items.push({
      title: get('title'),
      link: get('link'),
      description: get('description'),
      pubDate: get('pubDate'),
    });
  }

  return items;
}

function extractPermalink(url, lang) {
  const path = url.replace(SITE_URL, '');
  if (lang === 'ko') return path.replace(/^\/ko/, '');
  if (lang === 'en') return path.replace(/^\/en/, '');
  return path;
}

function extractCategory(permalink) {
  const parts = permalink.split('/').filter(Boolean);
  return parts.length > 0 ? parts[0] : '';
}

export function readAllFeeds(projectRoot) {
  const missing = [];
  const feedData = {};

  for (const { lang, path } of FEEDS) {
    const fullPath = resolve(projectRoot, path);
    if (!existsSync(fullPath)) {
      missing.push(path);
      continue;
    }
    const xml = readFileSync(fullPath, 'utf-8');
    feedData[lang] = parseItems(xml);
  }

  if (missing.length === FEEDS.length) {
    return { error: 'no-feeds', missing };
  }

  // 언어별 포스트를 permalink 기준으로 그룹핑
  const postMap = new Map();

  for (const { lang } of FEEDS) {
    const items = feedData[lang];
    if (!items) continue;

    for (const item of items) {
      const permalink = extractPermalink(item.link, lang);
      if (!postMap.has(permalink)) {
        postMap.set(permalink, {
          permalink,
          date: new Date(item.pubDate).toISOString().split('T')[0],
          category: extractCategory(permalink),
          versions: {},
        });
      }
      postMap.get(permalink).versions[lang] = {
        title: item.title,
        url: item.link,
        description: item.description,
      };
    }
  }

  const posts = Array.from(postMap.values()).sort(
    (a, b) => new Date(b.date) - new Date(a.date)
  );

  return { posts, missing };
}

XML 파싱을 별도 라이브러리 없이 정규식으로 처리하고 있습니다. RSS 피드의 구조가 단순하기 때문에 이 정도면 충분합니다.

공유 상태 관리 — status-manager.mjs

각 포스트가 어떤 플랫폼에 공유되었는지 추적하는 모듈입니다. share-status.json 파일에 상태를 저장하고, 새 포스트 동기화, 미공유 항목 조회, 공유 완료 표시 등의 기능을 제공합니다.

// scripts/lib/status-manager.mjs
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';

const BASE_PLATFORMS = ['facebook', 'linkedin', 'threads', 'bluesky'];

let _platforms = null;

export function getPlatforms() {
  if (_platforms) return _platforms;
  const platforms = [...BASE_PLATFORMS];
  // 환경 변수에서 Mastodon 인스턴스를 자동 감지
  for (const key of Object.keys(process.env)) {
    const match = key.match(/^MASTODON_(.+)_INSTANCE$/);
    if (match) platforms.push(`mastodon:${match[1].toLowerCase()}`);
  }
  _platforms = platforms;
  return platforms;
}

function getStatusPath(projectRoot) {
  return resolve(projectRoot, 'scripts/share-status.json');
}

export function loadStatus(projectRoot) {
  const path = getStatusPath(projectRoot);
  if (!existsSync(path)) return [];
  return JSON.parse(readFileSync(path, 'utf-8'));
}

export function saveStatus(projectRoot, status) {
  const path = getStatusPath(projectRoot);
  writeFileSync(path, JSON.stringify(status, null, 2) + '\n', 'utf-8');
}

export function syncStatus(projectRoot, feedPosts) {
  const status = loadStatus(projectRoot);
  const existing = new Set(status.map((s) => s.permalink));
  let added = 0;

  // 새 플랫폼이 추가된 경우 기존 항목에도 키를 추가
  for (const entry of status) {
    for (const p of getPlatforms()) {
      if (!entry.shared[p]) entry.shared[p] = [];
    }
  }

  for (const post of feedPosts) {
    if (existing.has(post.permalink)) {
      // 기존 항목에 새 언어가 추가된 경우 업데이트
      const entry = status.find((s) => s.permalink === post.permalink);
      for (const [lang, version] of Object.entries(post.versions)) {
        if (!entry.versions[lang]) {
          entry.versions[lang] = version;
        }
      }
      continue;
    }

    // 새 포스트 추가 — 모든 플랫폼이 "미공유" 상태
    status.push({
      permalink: post.permalink,
      date: post.date,
      category: post.category,
      versions: post.versions,
      shared: Object.fromEntries(getPlatforms().map((p) => [p, []])),
    });
    added++;
  }

  // 날짜 내림차순 정렬 후 저장
  status.sort((a, b) => new Date(b.date) - new Date(a.date));
  saveStatus(projectRoot, status);

  return { added, total: status.length };
}

export function getUnshared(
  status,
  { post, only, oldest, latest, platforms: platformOverride } = {}
) {
  let entries = post ? status.filter((s) => s.permalink === post) : status;

  const allPlatforms = platformOverride || getPlatforms();
  const platforms = only
    ? allPlatforms.filter((p) => p === only || p.startsWith(`${only}:`))
    : allPlatforms;

  if (oldest || latest) {
    // 미공유 언어가 있는 항목만 필터링
    const unsharedEntries = entries.filter((entry) =>
      platforms.some((platform) => {
        if (!entry.shared[platform]) return false;
        const langs = Object.keys(entry.versions);
        return langs.some((lang) => !entry.shared[platform].includes(lang));
      })
    );

    if (unsharedEntries.length > 0) {
      // status는 날짜 내림차순이므로 마지막 = 가장 오래된, 첫번째 = 최신
      const target = oldest
        ? unsharedEntries[unsharedEntries.length - 1]
        : unsharedEntries[0];
      entries = [target];
    } else {
      entries = [];
    }
  }

  const tasks = [];

  for (const entry of entries) {
    for (const platform of platforms) {
      if (!entry.shared[platform]) continue;
      const langs = Object.keys(entry.versions);
      const unsharedLangs = langs.filter(
        (lang) => !entry.shared[platform].includes(lang)
      );
      if (unsharedLangs.length > 0) {
        tasks.push({ entry, platform, langs: unsharedLangs });
      }
    }
  }

  return tasks;
}

export function markShared(projectRoot, permalink, platform, lang) {
  const status = loadStatus(projectRoot);
  const entry = status.find((s) => s.permalink === permalink);
  if (!entry) return;
  if (!entry.shared[platform]) entry.shared[platform] = [];
  if (!entry.shared[platform].includes(lang)) {
    entry.shared[platform].push(lang);
  }
  saveStatus(projectRoot, status);
}

share-status.json의 구조는 다음과 같습니다. 각 포스트별로 언어 버전 정보와 플랫폼별 공유 완료 언어 목록을 관리합니다.

[
  {
    "permalink": "/astro/migration-reason/",
    "date": "2026-03-24",
    "category": "astro",
    "versions": {
      "ja": {
        "title": "...",
        "url": "https://deku.posstree.com/astro/migration-reason/",
        "description": "..."
      },
      "ko": {
        "title": "...",
        "url": "https://deku.posstree.com/ko/astro/migration-reason/",
        "description": "..."
      },
      "en": {
        "title": "...",
        "url": "https://deku.posstree.com/en/astro/migration-reason/",
        "description": "..."
      }
    },
    "shared": {
      "facebook": ["ja", "ko"],
      "linkedin": ["ja", "ko", "en"],
      "threads": [],
      "bluesky": ["ja"],
      "mastodon:social": []
    }
  }
]

메인 CLI — share.mjs

share.mjs는 CLI 진입점으로, sync, status, post 세 가지 커맨드를 처리합니다. 위에서 설명한 모듈들을 조합하여 실제 공유 작업을 수행합니다.

// scripts/share.mjs
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from 'dotenv';
import { readAllFeeds } from './lib/rss-reader.mjs';
import {
  loadStatus,
  syncStatus,
  getUnshared,
  markShared,
} from './lib/status-manager.mjs';
import {
  formatFacebook,
  formatLinkedIn,
  formatThreads,
  formatBluesky,
  formatMastodon,
} from './lib/formatter.mjs';
import { log } from './lib/logger.mjs';
import * as facebook from './lib/platforms/facebook.mjs';
import * as linkedin from './lib/platforms/linkedin.mjs';
import * as threads from './lib/platforms/threads.mjs';
import * as bluesky from './lib/platforms/bluesky.mjs';
import { discoverPlatforms as discoverMastodon } from './lib/platforms/mastodon.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');

config({ path: resolve(ROOT, '.env') });

// 모든 플랫폼 모듈을 하나의 객체로 통합
const PLATFORMS = {
  facebook,
  linkedin,
  threads,
  bluesky,
  ...discoverMastodon(),
};
const LANGS = ['ja', 'ko', 'en'];

function parseArgs(args) {
  const opts = {
    post: null,
    dryRun: false,
    only: null,
    oldest: false,
    latest: false,
  };
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--post' && args[i + 1]) opts.post = args[++i];
    if (args[i] === '--dry-run') opts.dryRun = true;
    if (args[i] === '--only' && args[i + 1]) opts.only = args[++i];
    if (args[i] === '--oldest') opts.oldest = true;
    if (args[i] === '--latest') opts.latest = true;
  }
  return opts;
}

// --- sync: RSS 피드 → share-status.json 동기화 ---
function cmdSync() {
  log.info('Syncing RSS feeds → share-status.json...');

  const result = readAllFeeds(ROOT);
  if (result.error === 'no-feeds') {
    log.error('No RSS feeds found in dist/.');
    log.plain('  Run `npm run build` first to generate RSS feeds.');
    process.exit(1);
  }

  if (result.missing.length > 0) {
    log.warn(`Missing feeds: ${result.missing.join(', ')}`);
  }

  const { added, total } = syncStatus(ROOT, result.posts);
  log.success(`Synced: ${added} new post(s) added (${total} total)`);
}

// --- status: 공유 상태 출력 ---
function cmdStatus(args = []) {
  const status = loadStatus(ROOT);
  if (status.length === 0) {
    log.warn('No posts in share-status.json. Run `npm run share:sync` first.');
    return;
  }

  let limit = 0;
  let order = 'latest';
  let pendingOnly = false;
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--latest' && args[i + 1]) limit = parseInt(args[++i], 10);
    if (args[i] === '--oldest' && args[i + 1]) {
      limit = parseInt(args[++i], 10);
      order = 'oldest';
    }
    if (args[i] === '--pending') pendingOnly = true;
  }

  let entries = status;
  if (pendingOnly) {
    entries = entries.filter((entry) => {
      const langs = Object.keys(entry.versions);
      return Object.keys(PLATFORMS).some((platform) => {
        const shared = entry.shared[platform] || [];
        return langs.some((lang) => !shared.includes(lang));
      });
    });
  }
  if (order === 'oldest') entries = [...entries].reverse();
  if (limit > 0) entries = entries.slice(0, limit);

  log.plain('');
  for (const entry of entries) {
    const langs = Object.keys(entry.versions);
    log.plain(`${entry.permalink} (${entry.date})`);

    const platformNames = Object.keys(PLATFORMS);
    const maxLen = Math.max(...platformNames.map((n) => n.length));
    for (const platform of platformNames) {
      const shared = entry.shared[platform] || [];
      const marks = langs
        .map((lang) => (shared.includes(lang) ? `${lang}` : `${lang}`))
        .join('  ');
      const unsharedCount = langs.filter((l) => !shared.includes(l)).length;
      const suffix = unsharedCount > 0 ? `   ← 미공유 ${unsharedCount}` : '';
      const pad = platform.padEnd(maxLen);
      log.plain(`  ${pad}: ${marks}${suffix}`);
    }
    log.plain('');
  }
}

// --- post: 실제 공유 실행 ---
async function cmdPost(args) {
  const opts = parseArgs(args);
  const status = loadStatus(ROOT);

  if (status.length === 0) {
    log.warn('No posts in share-status.json. Run `npm run share:sync` first.');
    return;
  }

  // 설정된 플랫폼만 필터링 (--only로 특정 플랫폼만 지정 가능)
  const targetPlatforms = Object.keys(PLATFORMS).filter(
    (name) =>
      !opts.only || name === opts.only || name.startsWith(`${opts.only}:`)
  );
  const activePlatforms = {};
  for (const name of targetPlatforms) {
    if (PLATFORMS[name].isConfigured()) {
      activePlatforms[name] = PLATFORMS[name];
    } else if (!opts.dryRun) {
      log.dim(`  SKIP ${name} (credentials not configured)`);
    }
  }

  const configuredPlatforms = opts.dryRun
    ? undefined
    : Object.keys(activePlatforms);
  const tasks = getUnshared(status, {
    post: opts.post,
    only: opts.only,
    oldest: opts.oldest,
    latest: opts.latest,
    platforms: configuredPlatforms,
  });

  if (tasks.length === 0) {
    log.success('All posts are already shared!');
    return;
  }

  let successCount = 0;
  let failCount = 0;

  for (const task of tasks) {
    for (const lang of task.langs) {
      const version = task.entry.versions[lang];
      if (!version) continue;

      const label = `[${task.platform}] ${lang} ${task.entry.permalink}`;

      if (opts.dryRun) {
        log.info(`DRY RUN: ${label}`);
        successCount++;
        continue;
      }

      const mod = activePlatforms[task.platform];
      if (!mod) continue;

      // 언어별 크레덴셜 확인 (Facebook은 언어별 페이지를 사용)
      if (mod.isConfiguredForLang && !mod.isConfiguredForLang(lang)) {
        log.dim(`  SKIP ${label} (credentials not configured for ${lang})`);
        continue;
      }

      try {
        const result = await mod.post(version, task.entry.category, lang);
        markShared(ROOT, task.entry.permalink, task.platform, lang);
        log.success(`${label}`);
        log.dim(`${result.url}`);
        successCount++;
      } catch (err) {
        log.error(`${label}${err.message}`);
        failCount++;
      }
    }
  }

  log.plain('');
  log.bold(`Results: ${successCount} succeeded, ${failCount} failed`);
}

// --- 메인 ---
const command = process.argv[2];
const restArgs = process.argv.slice(3);

switch (command) {
  case 'sync':
    cmdSync();
    break;
  case 'status':
    cmdStatus(restArgs);
    break;
  case 'post':
    await cmdPost(restArgs);
    break;
  default:
    log.plain('Usage:');
    log.plain('  npm run share:sync     Sync RSS feeds to status file');
    log.plain('  npm run share:status   Show share status');
    log.plain('  npm run share          Post unshared items');
    process.exit(1);
}

플랫폼별 메시지 포맷 — formatter.mjs

각 플랫폼에 맞는 메시지 형식으로 포스트 정보를 포맷팅합니다. 플랫폼마다 글자 수 제한과 링크 처리 방식이 다르므로 개별 함수로 분리했습니다.

// scripts/lib/formatter.mjs
export function formatFacebook(version) {
  return {
    message: `${version.title}\n\n${version.description}`,
    link: version.url,
  };
}

export function formatLinkedIn(version, category) {
  const hashtag = category ? `#${category}` : '';
  const commentary = [version.title, '', version.description, '', hashtag]
    .filter((line) => line !== undefined)
    .join('\n')
    .trim();

  return {
    commentary,
    articleUrl: version.url,
  };
}

export function formatBluesky(version, category) {
  const hashtag = category ? `#${category}` : '';
  const parts = [version.title, '', version.description, '', version.url];
  if (hashtag) parts.push('', hashtag);

  // Bluesky는 300 grapheme 제한이 있음
  let text = parts.join('\n').trim();
  if ([...text].length > 300) {
    // 설명을 빼고 제목 + URL + 해시태그만 사용
    const base = [version.title, '', version.url];
    if (hashtag) base.push('', hashtag);
    text = base.join('\n').trim();
  }

  return { text };
}

export function formatMastodon(version, category) {
  const hashtag = category ? `#${category}` : '';
  const parts = [version.title, '', version.description, '', version.url];
  if (hashtag) parts.push('', hashtag);

  // Mastodon 기본 제한은 500자
  return { text: parts.join('\n').trim() };
}

export function formatThreads(version, category) {
  const hashtag = category ? `#${category}` : '';
  const parts = [version.title, '', version.description, '', version.url];
  if (hashtag) parts.push('', hashtag);

  return { text: parts.join('\n').trim() };
}

플랫폼 모듈 구현

각 플랫폼 모듈은 동일한 인터페이스(isConfigured() + post())를 따릅니다. 새 플랫폼을 추가할 때도 이 두 함수만 구현하면 됩니다.

Facebook

Facebook은 언어별로 다른 페이지를 운영하므로, isConfiguredForLang() 함수를 추가로 구현합니다. 각 언어의 페이지 ID와 액세스 토큰은 FACEBOOK_PAGE_ID_JA, FACEBOOK_PAGE_ACCESS_TOKEN_JA 형태의 환경 변수로 관리합니다.

// scripts/lib/platforms/facebook.mjs
import { formatFacebook } from '../formatter.mjs';

const GRAPH_API = 'https://graph.facebook.com/v21.0';
const LANGS = ['ja', 'ko', 'en'];

function getCredentials(lang) {
  const suffix = lang.toUpperCase();
  return {
    pageId: process.env[`FACEBOOK_PAGE_ID_${suffix}`],
    token: process.env[`FACEBOOK_PAGE_ACCESS_TOKEN_${suffix}`],
  };
}

export function isConfigured() {
  return LANGS.some((lang) => {
    const { pageId, token } = getCredentials(lang);
    return !!(pageId && token);
  });
}

export function isConfiguredForLang(lang) {
  const { pageId, token } = getCredentials(lang);
  return !!(pageId && token);
}

export async function post(version, category, lang) {
  const { pageId, token } = getCredentials(lang);
  if (!pageId || !token) {
    throw new Error(`Facebook credentials not configured for lang: ${lang}`);
  }

  const { message, link } = formatFacebook(version);

  const res = await fetch(`${GRAPH_API}/${pageId}/feed`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message, link, access_token: token }),
  });

  if (!res.ok) {
    const error = await res.json();
    throw new Error(
      `Facebook API error (${res.status}): ${error.error?.message}`
    );
  }

  const data = await res.json();
  return { id: data.id, url: `https://www.facebook.com/${data.id}` };
}

LinkedIn

LinkedIn API는 Article 형태로 포스트를 공유합니다. X-Restli-Protocol-VersionLinkedIn-Version 헤더가 필수입니다.

// scripts/lib/platforms/linkedin.mjs
import { formatLinkedIn } from '../formatter.mjs';

const API_URL = 'https://api.linkedin.com/rest/posts';

export function isConfigured() {
  return !!(
    process.env.LINKEDIN_ACCESS_TOKEN && process.env.LINKEDIN_PERSON_URN
  );
}

export async function post(version, category) {
  const { commentary, articleUrl } = formatLinkedIn(version, category);
  const token = process.env.LINKEDIN_ACCESS_TOKEN;
  const personUrn = process.env.LINKEDIN_PERSON_URN;

  const body = {
    author: personUrn,
    commentary,
    visibility: 'PUBLIC',
    distribution: { feedDistribution: 'MAIN_FEED' },
    content: {
      article: {
        source: articleUrl,
        title: version.title,
        description: version.description,
      },
    },
    lifecycleState: 'PUBLISHED',
  };

  const res = await fetch(API_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
      'X-Restli-Protocol-Version': '2.0.0',
      'LinkedIn-Version': '202602',
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`LinkedIn API error (${res.status}): ${text}`);
  }

  const postUrn = res.headers.get('x-restli-id');
  const shareId = postUrn ? postUrn.replace('urn:li:share:', '') : null;
  const url = shareId
    ? `https://www.linkedin.com/feed/update/urn:li:share:${shareId}/`
    : 'https://www.linkedin.com/feed/';
  return { id: postUrn || 'posted', url };
}

Threads

Threads API는 2단계로 동작합니다. 먼저 미디어 컨테이너를 생성(/threads)한 뒤, 해당 컨테이너를 발행(/threads_publish)합니다. 발행 후에는 permalink을 조회하여 공유된 포스트의 URL을 얻습니다.

// scripts/lib/platforms/threads.mjs
import { formatThreads } from '../formatter.mjs';

const API_BASE = 'https://graph.threads.net/v1.0';

export function isConfigured() {
  return !!(process.env.THREADS_USER_ID && process.env.THREADS_ACCESS_TOKEN);
}

export async function post(version, category) {
  const userId = process.env.THREADS_USER_ID;
  const token = process.env.THREADS_ACCESS_TOKEN;
  const { text } = formatThreads(version, category);

  // Step 1: 미디어 컨테이너 생성
  const createRes = await fetch(`${API_BASE}/${userId}/threads`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ media_type: 'TEXT', text, access_token: token }),
  });

  if (!createRes.ok) {
    const error = await createRes.json().catch(() => ({}));
    throw new Error(
      `Threads API error (${createRes.status}): ${error.error?.message}`
    );
  }

  const { id: containerId } = await createRes.json();

  // Step 2: 컨테이너 발행
  const publishRes = await fetch(`${API_BASE}/${userId}/threads_publish`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ creation_id: containerId, access_token: token }),
  });

  if (!publishRes.ok) {
    const error = await publishRes.json().catch(() => ({}));
    throw new Error(
      `Threads publish error (${publishRes.status}): ${error.error?.message}`
    );
  }

  const data = await publishRes.json();

  // Step 3: permalink 조회
  let url = 'https://www.threads.net';
  if (data.id) {
    try {
      const metaRes = await fetch(
        `${API_BASE}/${data.id}?fields=permalink&access_token=${token}`
      );
      if (metaRes.ok) {
        const meta = await metaRes.json();
        if (meta.permalink) url = meta.permalink;
      }
    } catch {
      // 실패 시 기본 URL 사용
    }
  }

  return { id: data.id || 'posted', url };
}

Bluesky

Bluesky는 AT Protocol을 사용합니다. 다른 플랫폼과 달리 텍스트 내 URL이 자동으로 링크되지 않으므로, facets라는 바이트 오프셋 기반의 리치 텍스트 시스템으로 직접 링크를 지정해야 합니다. 또한 embed로 링크 프리뷰(카드)를 첨부합니다.

// scripts/lib/platforms/bluesky.mjs
import { formatBluesky } from '../formatter.mjs';

const SERVICE = 'https://bsky.social';

export function isConfigured() {
  return !!(process.env.BLUESKY_IDENTIFIER && process.env.BLUESKY_APP_PASSWORD);
}

async function createSession() {
  const res = await fetch(`${SERVICE}/xrpc/com.atproto.server.createSession`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      identifier: process.env.BLUESKY_IDENTIFIER,
      password: process.env.BLUESKY_APP_PASSWORD,
    }),
  });

  if (!res.ok) {
    throw new Error(`Bluesky auth failed: ${res.statusText}`);
  }

  return res.json();
}

// 텍스트 내 URL의 바이트 오프셋을 계산하여 facets 생성
function detectUrlFacets(text) {
  const encoder = new TextEncoder();
  const facets = [];
  const urlRegex = /https?:\/\/[^\s)]+/g;
  let match;

  while ((match = urlRegex.exec(text)) !== null) {
    const beforeBytes = encoder.encode(text.slice(0, match.index)).byteLength;
    const urlBytes = encoder.encode(match[0]).byteLength;

    facets.push({
      index: { byteStart: beforeBytes, byteEnd: beforeBytes + urlBytes },
      features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }],
    });
  }

  return facets;
}

export async function post(version, category) {
  const session = await createSession();
  const { text } = formatBluesky(version, category);
  const facets = detectUrlFacets(text);

  const record = {
    $type: 'app.bsky.feed.post',
    text,
    facets,
    createdAt: new Date().toISOString(),
    embed: {
      $type: 'app.bsky.embed.external',
      external: {
        uri: version.url,
        title: version.title,
        description: version.description || '',
      },
    },
  };

  const res = await fetch(`${SERVICE}/xrpc/com.atproto.repo.createRecord`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${session.accessJwt}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      repo: session.did,
      collection: 'app.bsky.feed.post',
      record,
    }),
  });

  if (!res.ok) {
    const error = await res.json().catch(() => ({}));
    throw new Error(`Bluesky API error (${res.status}): ${error.message}`);
  }

  const data = await res.json();
  const rkey = data.uri?.split('/').pop();
  const handle = process.env.BLUESKY_IDENTIFIER;
  const url = rkey
    ? `https://bsky.app/profile/${handle}/post/${rkey}`
    : 'https://bsky.app';

  return { id: data.uri || 'posted', url };
}

Mastodon

Mastodon은 여러 인스턴스를 동시에 지원할 수 있도록 discoverPlatforms 패턴을 사용합니다. 환경 변수에서 MASTODON_{NAME}_INSTANCEMASTODON_{NAME}_ACCESS_TOKEN 쌍을 자동으로 감지하여 플랫폼 모듈을 동적으로 생성합니다.

// scripts/lib/platforms/mastodon.mjs
import { formatMastodon } from '../formatter.mjs';

export function discoverPlatforms() {
  const platforms = {};

  for (const key of Object.keys(process.env)) {
    const match = key.match(/^MASTODON_(.+)_INSTANCE$/);
    if (!match) continue;

    const suffix = match[1];
    const name = `mastodon:${suffix.toLowerCase()}`;
    const instance = process.env[`MASTODON_${suffix}_INSTANCE`]?.replace(
      /\/+$/,
      ''
    );
    const token = process.env[`MASTODON_${suffix}_ACCESS_TOKEN`];

    platforms[name] = {
      isConfigured: () => !!(instance && token),
      post: async (version, category) =>
        postTo(instance, token, version, category),
    };
  }

  return platforms;
}

async function postTo(instance, token, version, category) {
  const { text } = formatMastodon(version, category);

  const res = await fetch(`${instance}/api/v1/statuses`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ status: text }),
  });

  if (!res.ok) {
    const error = await res.json().catch(() => ({}));
    throw new Error(`Mastodon API error (${res.status}): ${error.error}`);
  }

  const data = await res.json();
  return { id: data.id || 'posted', url: data.url || instance };
}

예를 들어 .env에 다음과 같이 설정하면:

MASTODON_SOCIAL_INSTANCE=https://mastodon.social
MASTODON_SOCIAL_ACCESS_TOKEN=...
MASTODON_MSTDN_INSTANCE=https://mstdn.jp
MASTODON_MSTDN_ACCESS_TOKEN=...

mastodon:socialmastodon:mstdn 두 개의 플랫폼이 자동으로 등록됩니다.

터미널 컬러 로거 — logger.mjs

마지막으로 터미널 출력에 색상을 입혀주는 간단한 로거입니다.

// scripts/lib/logger.mjs
const colors = {
  reset: '\x1b[0m',
  bold: '\x1b[1m',
  dim: '\x1b[2m',
  red: '\x1b[31m',
  green: '\x1b[32m',
  yellow: '\x1b[33m',
  blue: '\x1b[34m',
  cyan: '\x1b[36m',
};

const c = (color, text) => `${colors[color]}${text}${colors.reset}`;

export const log = {
  info: (msg) => console.log(c('cyan', msg)),
  success: (msg) => console.log(c('green', `${msg}`)),
  warn: (msg) => console.log(c('yellow', `${msg}`)),
  error: (msg) => console.error(c('red', `${msg}`)),
  dim: (msg) => console.log(c('dim', msg)),
  bold: (msg) => console.log(c('bold', msg)),
  plain: (msg) => console.log(msg),
};

환경 변수 설정

모든 플랫폼의 API 크레덴셜은 프로젝트 루트의 .env 파일에서 관리합니다. 이 파일은 .gitignore에 포함되어 있어 저장소에 커밋되지 않습니다.

# .env

# Facebook (언어별 페이지)
FACEBOOK_APP_ID=...
FACEBOOK_APP_SECRET=...
FACEBOOK_PAGE_ID_JA=...
FACEBOOK_PAGE_ACCESS_TOKEN_JA=...
FACEBOOK_PAGE_ID_KO=...
FACEBOOK_PAGE_ACCESS_TOKEN_KO=...
FACEBOOK_PAGE_ID_EN=...
FACEBOOK_PAGE_ACCESS_TOKEN_EN=...

# LinkedIn
LINKEDIN_ACCESS_TOKEN=...
LINKEDIN_PERSON_URN=urn:li:person:...

# Threads
THREADS_USER_ID=...
THREADS_ACCESS_TOKEN=...

# Bluesky
BLUESKY_IDENTIFIER=...
BLUESKY_APP_PASSWORD=...

# Mastodon (인스턴스별)
MASTODON_SOCIAL_INSTANCE=https://mastodon.social
MASTODON_SOCIAL_ACCESS_TOKEN=...

배포 파이프라인과의 연동

이 공유 시스템은 배포 파이프라인에 자연스럽게 통합되어 있습니다. npm run deploy를 실행하면 predeploy 훅에 의해 자동으로 share:sync가 먼저 실행되어, 새 포스트가 share-status.json에 등록됩니다.

{
  "predeploy": "npm run share:sync"
}

전체 흐름을 정리하면 다음과 같습니다.

npm run deploy
  → predeploy: npm run share:sync
    → preshare:sync: npm run build
      → build: astro build && pagefind
    → share:sync: node scripts/share.mjs sync
  → deploy: gh-pages -d dist

배포까지 완료된 후에는 별도로 npm run share를 실행하여 새 포스트를 소셜 미디어에 공유합니다. 배포와 공유를 분리한 이유는, 배포가 성공했는지 확인한 후에 공유하는 것이 안전하기 때문입니다.

완료

이번 글에서는 블로그 포스트의 소셜 공유 자동화 시스템의 전체 코드를 공유했습니다.

  • rss-reader.mjs: 빌드된 RSS 피드에서 언어별 포스트 정보 추출
  • status-manager.mjs: share-status.json으로 포스트별/플랫폼별 공유 상태 추적
  • share.mjs: sync, status, post 세 가지 커맨드를 처리하는 CLI
  • formatter.mjs: 플랫폼별 글자 수 제한에 맞는 메시지 포맷팅
  • 5개 플랫폼 모듈: Facebook(언어별 페이지), LinkedIn, Threads(2단계 발행), Bluesky(AT Protocol facets), Mastodon(다중 인스턴스)
  • predeploy 훅을 통한 배포 파이프라인과의 자연스러운 연동

다음 글 트러블슈팅과 팁에서는 마이그레이션 과정에서 겪었던 문제들과 해결 방법을 공유합니다.

시리즈 안내

이 포스트는 Jekyll에서 Astro로 마이그레이션 시리즈의 일부입니다.

  1. Jekyll에서 Astro로 마이그레이션한 이유
  2. Astro 설치 및 프로젝트 구성
  3. Content Collections와 마크다운 마이그레이션
  4. 다국어(i18n) 구현
  5. SEO 구현
  6. 이미지 최적화 — 커스텀 rehype 플러그인
  7. 댓글 시스템 (Utterances)
  8. 광고 연동 (Google AdSense)
  9. Pagefind를 이용한 검색 구현
  10. 레이아웃과 컴포넌트 아키텍처
  11. GitHub Pages 배포
  12. 소셜 공유 자동화 스크립트
  13. 트러블슈팅과 팁

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS