목차
개요
이전 글 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
전체 흐름은 다음과 같습니다.
rss-reader.mjs가 빌드된 RSS 피드에서 포스트 목록을 추출status-manager.mjs가share-status.json과 비교하여 새 포스트를 등록share.mjs가 미공유 포스트를 찾아 각 플랫폼 모듈의post()함수를 호출- 각 플랫폼 모듈이
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(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/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은 언어별로 다른 페이지를 운영하므로, 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 API는 Article 형태로 포스트를 공유합니다. X-Restli-Protocol-Version과 LinkedIn-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}_INSTANCE와 MASTODON_{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:social과 mastodon: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 세 가지 커맨드를 처리하는 CLIformatter.mjs: 플랫폼별 글자 수 제한에 맞는 메시지 포맷팅- 5개 플랫폼 모듈: Facebook(언어별 페이지), LinkedIn, Threads(2단계 발행), Bluesky(AT Protocol facets), Mastodon(다중 인스턴스)
predeploy훅을 통한 배포 파이프라인과의 자연스러운 연동
다음 글 트러블슈팅과 팁에서는 마이그레이션 과정에서 겪었던 문제들과 해결 방법을 공유합니다.
시리즈 안내
이 포스트는 Jekyll에서 Astro로 마이그레이션 시리즈의 일부입니다.
- Jekyll에서 Astro로 마이그레이션한 이유
- Astro 설치 및 프로젝트 구성
- Content Collections와 마크다운 마이그레이션
- 다국어(i18n) 구현
- SEO 구현
- 이미지 최적화 — 커스텀 rehype 플러그인
- 댓글 시스템 (Utterances)
- 광고 연동 (Google AdSense)
- Pagefind를 이용한 검색 구현
- 레이아웃과 컴포넌트 아키텍처
- GitHub Pages 배포
- 소셜 공유 자동화 스크립트
- 트러블슈팅과 팁
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.