[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.xmlko/feed.xmlen/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エントリポイントで、syncstatuspostの3つのコマンドを処理します。上記で説明したモジュールを組み合わせて実際の共有作業を実行します。

// 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())に従います。新しいプラットフォームを追加する際もこの2つの関数を実装するだけです。

Facebook

Facebookは言語ごとに異なるページを運営するため、isConfiguredForLang()関数を追加で実装します。各言語のページIDとアクセストークンはFACEBOOK_PAGE_ID_JAFACEBOOK_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の2つのプラットフォームが自動的に登録されます。

ターミナルカラーロガー — 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.mjsshare-status.jsonでポスト別/プラットフォーム別の共有状態を追跡
  • share.mjs:sync、status、postの3つのコマンドを処理するCLI
  • formatter.mjs:プラットフォーム別の文字数制限に合わせたメッセージフォーマット
  • 5つのプラットフォームモジュール:Facebook(言語別ページ)、LinkedIn、Threads(2段階公開)、Bluesky(AT Protocol facets)、Mastodon(複数インスタンス)
  • predeployフックによるデプロイパイプラインとの自然な連携

次の記事トラブルシューティングとTipsでは、マイグレーション過程で経験した問題とその解決方法を共有します。

シリーズ案内

このポストは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. トラブルシューティングとヒント

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

アプリ広報

今見てるブログを作成たDekuが開発したアプリを使ってみてください。
Dekuが開発したアプリはFlutterで開発されています。

興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。



SHARE
Twitter Facebook RSS