ソーシャルメディア投稿の自動化:コンテンツカレンダーからAPIまで
コンテンツのスケジュール設定から主要プラットフォームとのAPI連携まで、ソーシャルメディア自動化パイプラインを構築する。

製品のソーシャルメディアを管理するということは、プラットフォームごとに異なるAPIの癖、認証フロー、メディア要件、レート制限に対応しながら、一貫して投稿を行うことを意味します。週に3回、同じコンテンツを4つのプラットフォームに手動で投稿した後、私はスケジューリング、フォーマット、メディア処理、投稿を処理する自動化パイプラインを構築しました。これには、人間のレビューなしに何も公開されないようにするための承認ステップが含まれています。この記事では、そのアーキテクチャ、プラットフォーム固有の注意点、そしてカスタム構築と既存ツールの利用のトレードオフについて説明します。
プラットフォームAPIの現状
主要なプラットフォームにはそれぞれ独自のAPIエコシステムがあり、開発者体験は劇的に異なります。2026年初頭時点での各プラットフォームの現状を正直に述べます。
Twitter/X API
X APIは、プラットフォームの移行以来、大幅な変更が加えられてきました。現在のAPI(v2)は、投稿、メディアアップロード、および分析のエンドポイントを提供します。無料ティアでは月間1,500件の投稿と自身の投稿への読み取りアクセスが可能であり、ほとんどの自動化ユースケースには十分です。
認証モデルは、ユーザーコンテキストのアクション(ユーザーに代わって投稿する)にはPKCE付きのOAuth 2.0を、読み取り操作にはOAuth 2.0 App-Onlyを使用します。APIは合理的に文書化されており、開発者ポータルには明確なレート制限情報が提供されています。
import { TwitterApi } from 'twitter-api-v2';
const client = new TwitterApi({
appKey: process.env.TWITTER_API_KEY!,
appSecret: process.env.TWITTER_API_SECRET!,
accessToken: process.env.TWITTER_ACCESS_TOKEN!,
accessSecret: process.env.TWITTER_ACCESS_SECRET!,
});
async function postTweet(text: string, mediaIds?: string[]): Promise<string> {
const tweet = await client.v2.tweet({
text,
...(mediaIds && { media: { media_ids: mediaIds } }),
});
return tweet.data.id;
}
async function uploadMedia(filePath: string): Promise<string> {
const mediaId = await client.v1.uploadMedia(filePath);
return mediaId;
}
主な制限事項: 投稿には280文字の制限があります(プレミアムユーザーはより長い)。画像アップロードはJPEG、PNG、GIF、WEBPを最大5MBまでサポートします。動画アップロードはMP4を最大512MBまでサポートしますが、15MBを超えるファイルにはチャンクアップロードが必要です。スレッドはreply設定で複数の投稿を作成する必要があり、複雑さが増します。
Instagram Graph API
InstagramのAPIはMetaエコシステムの一部であり、InstagramプロフェッショナルアカウントにリンクされたFacebookビジネスページが必要です。セットアッププロセスは非常に複雑で、Metaアプリ、ビジネス認証(特定の機能用)、および適切な権限の組み合わせが必要です。
投稿フローは2段階です。まずメディアコンテナを作成し、次にそれを公開します。
async function postToInstagram(
igUserId: string,
imageUrl: string,
caption: string,
accessToken: string
): Promise<string> {
// Step 1: Create media container
const containerResponse = await fetch(
`https://graph.facebook.com/v19.0/${igUserId}/media`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_url: imageUrl, // Must be a publicly accessible URL
caption,
access_token: accessToken,
}),
}
);
const container = await containerResponse.json();
// Step 2: Publish the container
const publishResponse = await fetch(
`https://graph.facebook.com/v19.0/${igUserId}/media_publish`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
creation_id: container.id,
access_token: accessToken,
}),
}
);
const result = await publishResponse.json();
return result.id;
}
主な注意点:
- 画像は公開アクセス可能なURLでホストされている必要があります。バイナリデータを直接アップロードすることはできません。つまり、投稿する前にS3またはCDNでメディアをホストする必要があります。
- カルーセル投稿(複数画像)は、各画像に個別のコンテナを作成し、それらを参照するカルーセルコンテナを作成する必要があります。
- リール(動画)には追加要件があります:9:16のアスペクト比、3秒から90秒の間、そして動画は公開前に完全に処理されている必要があります。
- アクセストークンは期限切れになります。長期間有効なトークンは60日間有効で、定期的な更新が必要です。
LinkedIn API
LinkedInのコンテンツ投稿用APIは、「Community Management API」(以前は「Share API」)を使用します。認証には、個人プロフィールにはw_member_socialスコープ、企業ページにはw_organization_socialスコープを持つOAuth 2.0を使用します。
async function postToLinkedIn(
authorUrn: string, // "urn:li:person:xxx" or "urn:li:organization:xxx"
text: string,
accessToken: string
): Promise<string> {
const response = await fetch('https://api.linkedin.com/v2/posts', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202401',
},
body: JSON.stringify({
author: authorUrn,
commentary: text,
visibility: 'PUBLIC',
distribution: {
feedDistribution: 'MAIN_FEED',
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: 'PUBLISHED',
}),
});
const postId = response.headers.get('x-restli-id');
return postId || '';
}
主な制限事項: LinkedInのAPIドキュメントは、複数のバージョンと命名規則にわたって散在しています。レート制限は、ほとんどのエンドポイントでユーザーあたり、アプリケーションあたり1日100APIコールであり、投稿には十分ですが、分析も取得する場合は厳しくなります。画像アップロードには、登録されたアップロードリクエストを伴う別のアップロードフローが必要です。
TikTok Content Posting API
TikTokのコンテンツ公開用APIは比較的新しく、他のプラットフォームよりも制限されています。Content Posting APIへのアクセスを個別に申請する必要があり、承認には数週間かかる場合があります。
投稿フローには、公開の初期化、動画のアップロード、そして公開の確認が含まれます。
async function postToTikTok(
videoUrl: string,
caption: string,
accessToken: string
): Promise<string> {
// Initialize the publish
const initResponse = await fetch(
'https://open.tiktokapis.com/v2/post/publish/video/init/',
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
post_info: {
title: caption,
privacy_level: 'SELF_ONLY', // Start as private, change after review
disable_duet: false,
disable_comment: false,
disable_stitch: false,
},
source_info: {
source: 'PULL_FROM_URL',
video_url: videoUrl,
},
}),
}
);
const result = await initResponse.json();
return result.data.publish_id;
}
主な注意点: TikTokは、すべての自動投稿をそのように開示することを義務付けています。これには特定のAPIフラグがあります。動画はTikTokのコンテンツガイドラインとフォーマット要件を満たす必要があります。APIにはユーザーアカウントごとの厳格な1日あたりの投稿制限があります。最も重要なのは、TikTokのAPIエコシステムは頻繁に変更されるため、実装する前に最新のドキュメントを確認することです。
認証フロー
4つのプラットフォームすべてがOAuth 2.0を使用していますが、実装の詳細が異なるため、プラットフォーム固有の処理が必要です。
OAuthの連携
一般的なフローは次のとおりです。
- ユーザーをプラットフォームの認証URLにリダイレクトし、アプリの
client_idと要求されたscopesを含めます。 - ユーザーが許可を与えます。プラットフォームは認証
codeとともにredirect_uriにリダイレクトします。 codeをaccess_token(通常はrefresh_tokenも)と交換します。- APIコールに
access_tokenを使用します。期限切れになったら更新します。
// Generic OAuth handler
interface OAuthConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
authorizationUrl: string;
tokenUrl: string;
scopes: string[];
}
async function exchangeCodeForToken(
config: OAuthConfig,
code: string
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const response = await fetch(config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
};
}
トークン管理
トークンは期限切れになります。Instagramトークンは60日間有効です。LinkedInトークンは60日間有効です。Twitterトークンは取り消されるまで有効(OAuth 1.0aの場合)か、リフレッシュトークン付きで短期間有効(OAuth 2.0の場合)です。TikTokトークンは24時間有効で、リフレッシュトークンは365日間有効です。
トークン更新システムが必要です。
interface StoredToken {
platform: string;
accessToken: string;
refreshToken: string;
expiresAt: Date;
}
async function getValidToken(platform: string): Promise<string> {
const stored = await db.tokens.findUnique({ where: { platform } });
if (!stored) throw new Error(`No token stored for ${platform}`);
// Refresh if expiring within 5 minutes
if (stored.expiresAt < new Date(Date.now() + 5 * 60 * 1000)) {
const refreshed = await refreshToken(platform, stored.refreshToken);
await db.tokens.update({
where: { platform },
data: {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken || stored.refreshToken,
expiresAt: new Date(Date.now() + refreshed.expiresIn * 1000),
},
});
return refreshed.accessToken;
}
return stored.accessToken;
}
コンテンツスケジューリングアーキテクチャ
スケジューリングシステムは自動化パイプラインの中核です。タイムゾーン、プラットフォーム固有のフォーマット、メディア処理、および障害回復を処理する必要があります。
データモデル
interface ScheduledPost {
id: string;
content: {
text: string;
media?: MediaAttachment[];
platformOverrides?: Record<string, { text?: string }>;
};
platforms: Platform[];
scheduledAt: Date; // UTC
timezone: string; // IANA timezone for display
status: 'draft' | 'approved' | 'scheduled' | 'publishing' | 'published' | 'failed';
results: Record<string, PostResult>;
retryCount: number;
createdBy: string;
approvedBy?: string;
approvedAt?: Date;
}
interface MediaAttachment {
originalUrl: string;
processedUrls: Record<string, string>; // Platform-specific processed versions
type: 'image' | 'video';
altText?: string;
}
interface PostResult {
platform: string;
postId?: string;
url?: string;
error?: string;
publishedAt?: Date;
}
platformOverridesパターン
各プラットフォームには異なる文字数制限、ハッシュタグの慣習、およびフォーマットの規範があります。完全に別々の投稿を作成するのではなく、プラットフォーム固有のオーバーライドを持つ基本テキストを使用します。
const post: ScheduledPost = {
id: 'post-1',
content: {
text: 'Excited to announce our new feature: real-time order tracking. Customers can now see exactly where their delivery is. #product #launch',
platformOverrides: {
twitter: {
text: 'Just shipped: real-time order tracking. Your customers can now watch their delivery in real-time. #buildinpublic',
},
linkedin: {
text: 'Excited to announce our latest feature: real-time order tracking.\n\nThis was one of the most requested features from our restaurant partners. Customers can now see exactly where their delivery is, reducing "where is my food?" support tickets by an estimated 40%.\n\nBuilt with WebSockets and a lightweight location service. Happy to share the technical architecture with anyone interested.\n\n#productdevelopment #startups #foodtech',
},
},
},
platforms: ['twitter', 'instagram', 'linkedin'],
scheduledAt: new Date('2026-03-15T14:00:00Z'),
timezone: 'America/New_York',
status: 'approved',
results: {},
retryCount: 0,
createdBy: 'user-1',
};
Twitterには簡潔なバージョンが、LinkedInにはより長くプロフェッショナルなバージョンが適用されます。Instagramは基本テキストをキャプションとして使用します。このアプローチは、完全に別々のコンテンツカレンダーを維持することなく、プラットフォームの規範を尊重します。
メディア処理
メディア処理はパイプラインの中で最もエラーが発生しやすい部分です。各プラットフォームには、画像サイズ、ファイルサイズ、フォーマット、アスペクト比に関して異なる要件があります。
処理パイプライン
interface MediaRequirements {
maxWidth: number;
maxHeight: number;
maxFileSize: number; // bytes
supportedFormats: string[];
aspectRatios?: { min: number; max: number };
}
const platformRequirements: Record<string, MediaRequirements> = {
twitter: {
maxWidth: 4096,
maxHeight: 4096,
maxFileSize: 5 * 1024 * 1024, // 5MB
supportedFormats: ['jpeg', 'png', 'gif', 'webp'],
},
instagram: {
maxWidth: 1440,
maxHeight: 1440,
maxFileSize: 8 * 1024 * 1024,
supportedFormats: ['jpeg', 'png'],
aspectRatios: { min: 4 / 5, max: 1.91 },
},
linkedin: {
maxWidth: 4096,
maxHeight: 4096,
maxFileSize: 10 * 1024 * 1024,
supportedFormats: ['jpeg', 'png', 'gif'],
},
};
async function processMediaForPlatform(
originalUrl: string,
platform: string
): Promise<string> {
const requirements = platformRequirements[platform];
const image = await sharp(await downloadFile(originalUrl));
const metadata = await image.metadata();
let processed = image;
// Resize if exceeding max dimensions
if (
(metadata.width && metadata.width > requirements.maxWidth) ||
(metadata.height && metadata.height > requirements.maxHeight)
) {
processed = processed.resize(requirements.maxWidth, requirements.maxHeight, {
fit: 'inside',
withoutEnlargement: true,
});
}
// Enforce aspect ratio for Instagram
if (requirements.aspectRatios && metadata.width && metadata.height) {
const currentRatio = metadata.width / metadata.height;
if (
currentRatio < requirements.aspectRatios.min ||
currentRatio > requirements.aspectRatios.max
) {
// Crop to acceptable ratio
const targetRatio = Math.max(
requirements.aspectRatios.min,
Math.min(currentRatio, requirements.aspectRatios.max)
);
const newWidth = Math.round(metadata.height * targetRatio);
processed = processed.resize(newWidth, metadata.height, { fit: 'cover' });
}
}
// Convert to supported format
const format = requirements.supportedFormats.includes('webp') ? 'webp' : 'jpeg';
const buffer = await processed.toFormat(format, { quality: 85 }).toBuffer();
// Upload processed image and return URL
const processedUrl = await uploadToStorage(buffer, `processed/${platform}/${Date.now()}.${format}`);
return processedUrl;
}
画像処理にSharpを使用することで、パイプラインは高速に保たれ、EXIF回転、カラープロファイル変換、フォーマット互換性などのエッジケースを処理します。動画処理にはFFmpegがトランスコーディングを処理しますが、動画処理ははるかに複雑でリソースを大量に消費するため、通常は専用のワーカーまたはクラウド関数にオフロードします。
レート制限と再試行ロジック
すべてのプラットフォームがレート制限を課しており、それを不適切に超えると投稿の失敗や一時的なAPIバンにつながる可能性があります。
レート制限の追跡
interface RateLimitState {
platform: string;
remaining: number;
resetAt: Date;
limit: number;
}
class RateLimiter {
private limits: Map<string, RateLimitState> = new Map();
updateFromHeaders(platform: string, headers: Headers): void {
const remaining = parseInt(headers.get('x-rate-limit-remaining') || '100');
const resetTimestamp = parseInt(headers.get('x-rate-limit-reset') || '0');
this.limits.set(platform, {
platform,
remaining,
resetAt: new Date(resetTimestamp * 1000),
limit: parseInt(headers.get('x-rate-limit-limit') || '100'),
});
}
async waitIfNeeded(platform: string): Promise<void> {
const state = this.limits.get(platform);
if (!state) return;
if (state.remaining <= 1 && state.resetAt > new Date()) {
const waitMs = state.resetAt.getTime() - Date.now() + 1000; // 1s buffer
console.log(`Rate limited on ${platform}. Waiting ${waitMs}ms`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
}
指数関数的バックオフによる再試行
すべての失敗が永続的ではありません。ネットワークタイムアウト、一時的なサーバーエラー、レート制限はすべて再試行可能です。データベースエラーや認証失敗は再試行できません。
async function publishWithRetry(
post: ScheduledPost,
platform: string,
maxRetries: number = 3
): Promise<PostResult> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await rateLimiter.waitIfNeeded(platform);
const result = await publishToPlatform(post, platform);
return { platform, postId: result.id, url: result.url, publishedAt: new Date() };
} catch (error) {
const isRetryable =
error instanceof RateLimitError ||
error instanceof NetworkError ||
(error instanceof ApiError && error.statusCode >= 500);
if (!isRetryable || attempt === maxRetries) {
return {
platform,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
const backoffMs = Math.min(1000 * Math.pow(2, attempt), 30000);
const jitter = Math.random() * 1000;
await new Promise((resolve) => setTimeout(resolve, backoffMs + jitter));
}
}
return { platform, error: 'Max retries exceeded' };
}
ジッターは重要です。これがないと、同じバックオフスケジュールで同期された複数の再試行クライアントが、すべて同時に再試行する「サンダリング・ハード」問題を引き起こします。
コンテンツ承認ワークフロー
人間のレビューなしに自動投稿を行うことは危険です。フォーマットエラー、危機時の不適切な投稿、または誤ったリンクは、実際の損害を引き起こす可能性があります。私はパイプラインに必須の承認ステップを含めています。
承認フロー
コンテンツ作成 → ステータス: DRAFT
│
▼
プレビュー生成 → レビュー担当者に通知 (Slack/email)
│
▼
レビュー担当者が承認 → ステータス: APPROVED → 投稿スケジュール済み
│
または却下 → ステータス: DRAFT (フィードバック付き) → 作成者に戻る
プレビューステップは重要です。これにより、レビュー担当者は、文字数、メディアプレビュー、リンクプレビューとともに、各プラットフォームに何が投稿されるかを正確に確認できます。これは、コンテンツエディタでは明らかではないフォーマットの問題を捕捉します。
async function generatePreview(post: ScheduledPost): Promise<PlatformPreview[]> {
return post.platforms.map((platform) => {
const text =
post.content.platformOverrides?.[platform]?.text || post.content.text;
return {
platform,
text,
characterCount: text.length,
characterLimit: getCharacterLimit(platform),
isOverLimit: text.length > getCharacterLimit(platform),
mediaPreview: post.content.media?.map((m) => ({
url: m.processedUrls[platform] || m.originalUrl,
type: m.type,
altText: m.altText,
})),
estimatedReach: getEstimatedReach(platform),
};
});
}
function getCharacterLimit(platform: string): number {
const limits: Record<string, number> = {
twitter: 280,
instagram: 2200,
linkedin: 3000,
tiktok: 2200,
};
return limits[platform] || 2200;
}
分析収集
投稿後、エンゲージメントデータを収集することは、将来のコンテンツを最適化するのに役立ちます。各プラットフォームはAPIを通じて分析を提供しますが、データの形式と利用可能性は異なります。
interface PostAnalytics {
postId: string;
platform: string;
collectedAt: Date;
impressions: number;
engagements: number;
clicks: number;
shares: number;
comments: number;
likes: number;
engagementRate: number;
}
async function collectAnalytics(postResult: PostResult): Promise<PostAnalytics | null> {
if (!postResult.postId) return null;
switch (postResult.platform) {
case 'twitter':
return collectTwitterAnalytics(postResult.postId);
case 'linkedin':
return collectLinkedInAnalytics(postResult.postId);
case 'instagram':
return collectInstagramAnalytics(postResult.postId);
default:
return null;
}
}
私は投稿後1時間、24時間、7日後に分析を収集します。これにより、即時のエンゲージメント、日々のパフォーマンス、および長期的なリーチを把握できます。このデータは、どのコンテンツタイプ、投稿時間、プラットフォームが最もエンゲージメントを促進するかを示すシンプルなダッシュボードに供給されます。
構築 vs 購入:正直な評価
カスタムシステムを構築する前に、既存のツールがニーズを満たしているかどうかを検討してください。
既存ツールを使用する場合
Buffer(月額5〜100ドル)は、スケジューリング、マルチプラットフォーム投稿、および基本的な分析を処理します。Twitter、Instagram、LinkedIn、Facebook、Pinterest、TikTokをサポートしています。APIはプログラムによるスケジューリングに堅牢です。ほとんどの小規模チームにとって、Bufferはニーズの90%をカバーします。
Hootsuite(月額99ドル以上)は、チームコラボレーション、コンテンツ承認ワークフロー、およびより詳細な分析を追加します。複数の関係者がいる大規模チームに適しています。
Typefully(月額12〜29ドル)は、スレッドサポート、ドラフトコラボレーション、オーディエンス分析を備えたTwitter/Xに特化しています。Twitterが主要なプラットフォームである場合、これは優れています。
Later(月額16.67ドル以上)は、メディアライブラリ管理とビジュアルカレンダー計画を備えたビジュアルファーストプラットフォーム(Instagram、Pinterest、TikTok)に強力です。
カスタム構築する場合
カスタム構築が理にかなうのは次の場合です。
- 内部システム(CMS、製品データベース、CRM)との深い統合が必要な場合
- コンテンツ生成が部分的に自動化されている場合(AI生成ドラフト、製品イベントからのテンプレート投稿)
- 既存のツールがサポートしていないカスタム承認ワークフローが必要な場合
- SaaSの料金が高額になるほどの投稿量がある場合(アカウント全体で1日50件以上)
- 必要なプラットフォームAPI機能がサードパーティツールによって公開されていない場合
ハイブリッドアプローチ
手動コンテンツにはスケジューリングツール(Buffer、Hootsuite)を使用し、プログラムによる投稿のみにカスタム自動化を構築します。多くのスケジューリングツールには、ダッシュボードをレビューと承認に使用しながら、プログラムで投稿を作成できるAPIがあります。
// Using Buffer's API for scheduling
async function scheduleViaBuffer(
profileIds: string[],
text: string,
mediaUrls: string[],
scheduledAt: Date
): Promise<string> {
const response = await fetch('https://api.bufferapp.com/1/updates/create.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
access_token: process.env.BUFFER_ACCESS_TOKEN!,
text,
'profile_ids[]': profileIds.join(','),
scheduled_at: scheduledAt.toISOString(),
...(mediaUrls.length && { 'media[photo]': mediaUrls[0] }),
}),
});
const result = await response.json();
return result.updates[0].id;
}
これにより、確立されたツールの信頼性とUIを享受しつつ、カスタムコードのプログラム的な柔軟性を維持できます。
Webhook通知
投稿が公開されたとき、失敗したとき、または大きなエンゲージメントがあったとき、システムはチームに通知する必要があります。私はウェブフックを使用してSlackにイベントをプッシュします。
async function notifySlack(event: PostEvent): Promise<void> {
const color = event.type === 'published' ? '#36a64f' : '#ff0000';
const title =
event.type === 'published'
? `Posted to ${event.platform}`
: `Failed to post to ${event.platform}`;
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
attachments: [
{
color,
title,
text: event.type === 'published'
? `"${event.text.substring(0, 100)}..." — ${event.url}`
: `Error: ${event.error}`,
fields: [
{ title: 'Platform', value: event.platform, short: true },
{ title: 'Time', value: new Date().toLocaleString(), short: true },
],
},
],
}),
});
}
エラー処理パターン
ソーシャルメディアAPIは信頼性が低いです。サーバーがダウンしたり、レート制限が変更されたり、メディア処理が失敗したりします。システムはコンテンツを失うことなく、障害を適切に処理する必要があります。
デッドレターキュー
すべての再試行後に失敗した投稿は、手動レビューのためにデッドレターキューに送られます。
async function handleFailedPost(post: ScheduledPost, platform: string, error: string): Promise<void> {
// Update post status
await db.scheduledPosts.update({
where: { id: post.id },
data: {
status: 'failed',
results: {
...post.results,
[platform]: { platform, error },
},
},
});
// Add to dead letter queue
await db.failedPosts.create({
data: {
postId: post.id,
platform,
error,
originalContent: post.content,
failedAt: new Date(),
resolved: false,
},
});
// Notify team
await notifySlack({
type: 'failed',
platform,
text: post.content.text,
error,
});
}
ダッシュボードには、再試行ボタン付きのすべての失敗した投稿が表示されます。ほとんどの失敗は一時的なものであり、手動で再試行すると成功します。永続的な失敗は通常、トークンの期限切れまたはコード更新が必要なAPIの変更を示します。
部分的な成功の処理
3つのプラットフォームを対象とする投稿が、2つで成功し1つで失敗する場合があります。システムは、投稿全体を失敗と見なすのではなく、プラットフォームごとの結果を独立して追跡する必要があります。
async function publishToAllPlatforms(post: ScheduledPost): Promise<void> {
const results = await Promise.allSettled(
post.platforms.map((platform) => publishWithRetry(post, platform))
);
const postResults: Record<string, PostResult> = {};
let hasFailure = false;
results.forEach((result, index) => {
const platform = post.platforms[index];
if (result.status === 'fulfilled') {
postResults[platform] = result.value;
if (result.value.error) hasFailure = true;
} else {
postResults[platform] = { platform, error: result.reason.message };
hasFailure = true;
}
});
await db.scheduledPosts.update({
where: { id: post.id },
data: {
status: hasFailure ? 'failed' : 'published',
results: postResults,
},
});
}
Promise.allの代わりにPromise.allSettledを使用することで、1つのプラットフォームでの失敗が他のプラットフォームへの投稿を妨げないようにします。
全体のまとめ
完全な自動化パイプラインは次のようになります。
- コンテンツはウェブダッシュボードまたはAPIを介して作成されます(オプションでAIアシストによるドラフト作成も可能)。
- メディアはSharpを使用して、各ターゲットプラットフォーム用にアップロードおよび処理されます。
- 投稿は承認キューに入り、レビュー担当者がプラットフォーム固有のプレビューを確認します。
- 承認されると、投稿はUTCタイムスタンプとともにデータベースにスケジュールされます。
- cronジョブ(またはスケジュールされたクラウド関数)が、次の1分以内に期限が来る投稿をチェックします。
- 期限が来た各投稿について、パブリッシャーは各プラットフォームで認証し、メディアアップロードを処理し、公開します。
- 結果はプラットフォームごとに記録されます。失敗は指数関数的バックオフによる再試行をトリガーします。
- 再試行が尽きた場合は、Slack通知とともにデッドレターキューに送られます。
- 分析は1時間、24時間、7日間の間隔で収集されます。
週に2回投稿するソロファウンダーにとって、これは過剰設計でしょうか?間違いなくそうです。Bufferを使用してください。しかし、プログラムによるコンテンツニーズ、イベント駆動型投稿、または複雑な承認ワークフローを持つ製品の場合、カスタムパイプラインは一貫性と制御においてその価値を発揮します。プラットフォームAPIは十分に安定しており、ツールも十分に成熟しているため、実装は不十分に文書化されたAPIと格闘するのではなく、直接的なエンジニアリング作業となります。これは2年前には当てはまりませんでした。