Skip to main content
backend21 сентября 2025 г.14 мин чтения

Автоматизация публикаций в социальных сетях: от контент-плана до API

Создание конвейера автоматизации социальных сетей — от планирования контента до интеграции API с основными платформами.

automationapisocial-media
Автоматизация публикаций в социальных сетях: от контент-плана до API

Управление социальными сетями для продукта означает регулярные публикации на разных платформах, каждая из которых имеет свои особенности API, потоки аутентификации, требования к медиафайлам и ограничения по частоте запросов. После того как я вручную публиковал один и тот же контент на четырех платформах три раза в неделю, я создал конвейер автоматизации, который занимается планированием, форматированием, обработкой медиафайлов и публикацией — с этапом утверждения, чтобы ничто не выходило без человеческого контроля. Этот пост охватывает архитектуру, специфические для платформ подводные камни и компромиссы между созданием собственного решения и использованием существующих инструментов.

Обзор API платформ

Каждая крупная платформа имеет свою собственную экосистему API, и опыт разработчиков значительно различается. Вот честное состояние каждой из них по состоянию на начало 2026 года.

API Twitter/X

API X претерпел значительные изменения с момента перехода платформы. Текущий API (v2) предлагает конечные точки для публикации, загрузки медиафайлов и аналитики. Бесплатный уровень позволяет делать 1500 публикаций в месяц и предоставляет доступ для чтения к вашим собственным публикациям, что достаточно для большинства сценариев автоматизации.

Модель аутентификации использует OAuth 2.0 с PKCE для действий в контексте пользователя (публикация от имени пользователя) и 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 до 5 МБ. Загрузка видео поддерживает MP4 до 512 МБ, но требует пошаговой загрузки для файлов размером более 15 МБ. Темы требуют создания нескольких публикаций с настройками reply, что усложняет процесс.

Instagram Graph API

API Instagram является частью экосистемы Meta и требует наличия бизнес-страницы Facebook, связанной с профессиональным аккаунтом Instagram. Процесс настройки, как известно, запутан — вам потребуется приложение Meta, бизнес-верификация (для определенных функций) и правильная комбинация разрешений.

Поток публикации состоит из двух шагов: сначала создайте контейнер медиафайлов, затем опубликуйте его.

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 перед публикацией.
  • Публикации-карусели (несколько изображений) требуют создания отдельных контейнеров для каждого изображения, а затем контейнера-карусели, который ссылается на них.
  • Reels (видео) имеют дополнительные требования: соотношение сторон 9:16, продолжительность от 3 до 90 секунд, и видео должно быть полностью обработано перед публикацией.
  • Токены доступа истекают. Долгосрочные токены действуют 60 дней и требуют периодического обновления.

API LinkedIn

API LinkedIn для публикации контента использует "Community Management API" (ранее "Share API"). Аутентификация использует OAuth 2.0 с областью действия w_member_social для личных профилей или w_organization_social для страниц компаний.

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 || '';
}

Основные ограничения: Документация API LinkedIn разбросана по нескольким версиям и соглашениям об именовании. Ограничение по частоте запросов составляет 100 вызовов API в день на пользователя на приложение для большинства конечных точек, что является щедрым для публикации, но жестким, если вы также извлекаете аналитику. Загрузка изображений требует отдельного потока загрузки с зарегистрированным запросом на загрузку.

TikTok Content Posting API

API TikTok для публикации контента относительно новый и более ограниченный, чем у других платформ. Вам нужно специально подать заявку на доступ к 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 имеет строгие ежедневные ограничения на количество публикаций для каждой учетной записи пользователя. Самое главное, экосистема API TikTok часто меняется, поэтому перед внедрением проверьте текущую документацию.

Потоки аутентификации

Все четыре платформы используют OAuth 2.0, но детали реализации достаточно различаются, чтобы требовать специфической для платформы обработки.

Процесс OAuth

Общий поток выглядит так:

  1. Перенаправьте пользователя на URL авторизации платформы с client_id вашего приложения и запрошенными scopes.
  2. Пользователь предоставляет разрешение. Платформа перенаправляет обратно на ваш redirect_uri с кодом авторизации code.
  3. Обменяйте code на access_token (и обычно на refresh_token).
  4. Используйте access_token для вызовов API. Обновляйте его, когда он истекает.
// 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' };
}

Джиттер важен — без него несколько клиентов, пытающихся повторить запрос и синхронизированных по одному и тому же расписанию отсрочки, создают проблемы "громогласного стада", когда все они повторяют попытку одновременно.

Рабочий процесс утверждения контента

Автоматическая публикация без человеческого контроля рискованна. Ошибка форматирования, неуместный пост во время кризиса или неверная ссылка могут нанести реальный ущерб. Я включаю обязательный этап утверждения в конвейер.

Поток утверждения

Контент создан → Статус: ЧЕРНОВИК
       │
       ▼
Предварительный просмотр сгенерирован → Уведомление рецензента (Slack/email)
       │
       ▼
Рецензент утверждает → Статус: УТВЕРЖДЕНО → Запланировано к публикации
       │
   или отклоняет → Статус: ЧЕРНОВИК (с обратной связью) → Возврат создателю

Этап предварительного просмотра важен — он показывает рецензенту именно то, что будет опубликовано на каждой платформе, с подсчетом символов, предварительным просмотром медиафайлов и ссылок. Это позволяет выявить проблемы форматирования, которые не очевидны в редакторе контента.

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 дней после публикации. Это позволяет отслеживать немедленную вовлеченность, ежедневную производительность и долгосрочный охват. Данные поступают в простую панель управления, которая показывает, какие типы контента, время публикации и платформы обеспечивают наибольшую вовлеченность.

Создавать или покупать: честная оценка

Прежде чем создавать собственную систему, подумайте, соответствуют ли существующие инструменты вашим потребностям.

Когда использовать существующие инструменты

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)
  • Ваша генерация контента частично автоматизирована (черновики, сгенерированные ИИ, шаблонные публикации по событиям продукта)
  • Вам нужны пользовательские рабочие процессы утверждения, которые не поддерживаются существующими инструментами
  • Вы публикуете в таком объеме, что стоимость SaaS становится дорогой (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;
}

Это дает вам надежность и пользовательский интерфейс проверенного инструмента, сохраняя при этом программную гибкость пользовательского кода.

Уведомления через вебхуки

Когда публикация опубликована, не удалась или получила значительную вовлеченность, система должна уведомить команду. Я использую вебхуки для отправки событий в 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, требующие обновления кода.

Обработка частичного успеха

Публикация, предназначенная для трех платформ, может быть успешной на двух и неудачной на одной. Система должна отслеживать результаты для каждой платформы независимо, а не рассматривать всю публикацию как неудачную:

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.allSettled вместо Promise.all гарантирует, что сбой на одной платформе не помешает публикации на других.

Собираем все воедино

Полный конвейер

DU

Danil Ulmashev

Full Stack Developer

Хотите работать вместе?