Построил pipeline публикации контента на 8 платформах. Время распространения статьи сократилось с 50 минут до 90 секунд. Рассказываю, почему waterfall обходит parallel, какие API-ловушки встретились, и почему без человека в цикле нельзя.

Пишу каждый день. Каждый раз всё заканчивалось одинаково: открыть восемь вкладок, скопировать markdown, убрать форматирование для Telegram, сжать картинки для Mastodon, переписать хук для Bluesky (лимит 300 графем), вставить URL в каждую платформу, записать куда что ушло. Пятьдесят минут механической работы после завершения творческой части.

Попробовал Buffer и Zapier. Оба проваливаются по двум пунктам: не умеют адаптировать markdown в plaintext (Telegram и VK показывают `жирный как текст, не как жирный), и не поддерживают AT Protocol Bluesky или GraphQL Paragraph. Для нишевых платформ кастомные publisher'ы — единственный путь.

Моя задача состояла в том, чтобы оптимизировать весь этот процесс, сделать так, чтобы после написания статьи оставалось нажать одну кнопку — и через 90 секунд контент был на всех платформах, Я попытался реализовать эту архитектуру через Hermes.


Архитектура: почему waterfall, а не parallel

Первая попытка была наивной: запускать все платформы одновременно. Все провалилась сразу.

Проблема — цепочки зависимостей. Тизеры Bluesky нуждаются в URL WordPress, чтобы построить link cards. Нельзя запостить тизер до публикации канонической статьи. Dev.to и Paragraph имеют rate limits, которые при параллельном выполнении запускают каскад 429 на все publisher'ы. Одна платформа подвисает — убивает весь запуск.

Решение: waterfall с избирательной конкурентностью. Четыре стадии, каждая производит артефакты для следующей.

┌─────────────────────────────────────────────────────────────────────┐
│  PIPELINE: WATERFALL С ИЗБИРАТЕЛЬНОЙ КОНКУРЕНТНОСТЬЮ                     │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Стадия 1: Адаптация контента (последовательно)                       │
│    → Парсинг markdown-источника                                     │
│    → Генерация вариантов через Jinja2-шаблоны                      │
│    → Сжатие изображений под лимиты платформ (FFmpeg)                  │
│                                                                     │
│  Стадия 2: Primary Hub (последовательно, блокирующе)                  │
│    → WordPress: публикация полной статьи → получение канонического URL│
│    → Dev.to: публикация markdown-варианта → получение dev.to URL      │
│                                                                     │
│  Стадия 3: Социальные тизеры (параллельно, 4 потока)               │
│    → Bluesky: 300 графем + сжатое изображение + ссылка               │
│    → Mastodon: 500 символов + медиа-загрузка + ссылка                │
│    → Tumblr: фото-пост + подпись + ссылка                             │
│    → Telegram: plaintext + сжатое изображение + ссылка                │
│    → VK: plaintext + фото + ссылка                                    │
│                                                                     │
│  Стадия 4: Архивирование (последовательно)                            │
│    → Git-коммит со всеми опубликованными URL                          │
│    → Обновление индекса LLM-Wiki                                      │
│    → Запись execution log для отладки                                 │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Почему waterfall? Каждая стадия производит артефакты для следующей. URL WordPress становится канонической ссылкой для всех социальных платформ. Без него постится сиротский контент, исчезающий за 48 часов.


Стадия 1: Адаптация контента

Здесь умирает большинство pipeline'ов. Нельзя постить один markdown в Dev.to, Telegram и Bluesky. Dev.to рендерит ## заголовки нативно. Telegram показывает сырой #. Bluesky вырезает весь markdown и показывает plaintext.

Построил адаптационный слой на Jinja2 — один шаблон на платформу. Источник всегда один markdown-файл в vault LLM-Wiki. Адаптер читает его, применяет правила платформы, генерирует вариант.

# scripts/adaptation.py
import re
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
env = Environment(loader=FileSystemLoader(TEMPLATES_DIR))
def adapt_for_platform(source_md: str, platform: str) -> str:    """Генерация варианта под конкретную платформу из канонического markdown."""    template = env.get_template(f"{platform}.j2")        if platform in ("telegram", "vk"):        text = re.sub(r'\*\*(.*?)\*\*', r'\1', source_md)   # жирный        text = re.sub(r'\*(.*?)\*', r'\1', text)             # курсив        text = re.sub(r'__(.*?)__', r'\1', text)            # подчёркивание        text = re.sub(r'~~(.*?)~~', r'\1', text)            # зачёркивание        text = re.sub(r'\[(.*?)\]\((.*?)\)', r'\2', text)   # ссылки → голый URL        text = re.sub(r'!\[.*?\]\(.*?\)', '', text)        # удалить изображения        text = re.sub(r'#{1,6}\s+', '', text)              # удалить заголовки        return template.render(content=text, has_images=False)        elif platform == "bluesky":        text = re.sub(r'#{1,6}\s+', '', source_md)        text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)        text = re.sub(r'\n+', ' ', text)        return text[:270].strip() + "..."  # оставить место под URL        elif platform == "devto":        text = source_md.replace(".svg)", ".png)")        return template.render(content=text, tags=extract_tags(source_md))        elif platform == "wordpress":        return template.render(content=markdown_to_html(source_md))        elif platform == "mastodon":        text = re.sub(r'\*\*(.*?)\*\*', r'\1', source_md)        text = re.sub(r'\n+', ' ', text)        return text[:470].strip() + "..."        elif platform == "paragraph":        return template.render(content=source_md, images_are_external=True)        return source_md
def extract_tags(md: str) -> list:    """Извлечь теги из YAML frontmatter, ограничить до 4 для Dev.to."""    match = re.search(r'^tags:\s*(.+)', md, re.MULTILINE)    if match:        tags = [t.strip() for t in match.group(1).split(",")]        return tags[:4]    return ["python", "automation"]

Правила платформ:

Платформа

Markdown

Изображения

Лимит длины

Особенности

Telegram / VK

Полная очистка

Только превью по голому URL

~4000 символов

Голые URL для превью, без markdown

Bluesky

Полная очистка

Blob-загрузка (2 МБ макс)

300 графем

Подсчёт графем, не байтов

Mastodon

Полная очистка

Двухступенчатая загрузка

500 символов

POST /api/v1/mediaidPOST /api/v1/statuses

Dev.to

Нативный

Только PNG (SVG ломается)

Нет

Максимум 4 тега, режим draft

WordPress

HTML

Только внешние URL (free plan)

Нет

Scope posts только, без media

Paragraph

Нативный

Только внешние URL

Нет

GitHub raw URL блокируются hotlink

Адаптация занимает ~30 секунд — читает источник, применяет правила всех платформ, пишет 8 вариантов. Каждый версионируется в git.


Стадия 2: Primary Hub — WordPress и Dev.to

Две платформы публикуются первыми. Обе производят URL, необходимые downstream-платформам.

WordPress

WordPress — SEO-якорь. Все социальные тизеры ссылаются на него как на канонический источник. Социальные платформы не индексируются Google; WordPress — да. Без него постится сиротский контент.

# api/publishers/wordpress.py
import os
import requests
ACCESS_TOKEN = os.environ.get('WORDPRESS_ACCESS_TOKEN')
BLOG_ID = os.environ.get('WORDPRESS_BLOG_ID')
def publish_post(title: str, content: str, featured_image_url: str = None,                 categories: list = None, tags: str = "") -> dict:    url = f"https://public-api.wordpress.com/rest/v1.2/sites/{BLOG_ID}/posts/new"    headers = {        "Authorization": f"Bearer {ACCESS_TOKEN}",        "Content-Type": "application/json",    }    payload = {        "title": title,        "content": content,        "status": "publish",    }    if categories:        payload["categories"] = categories    if tags:        payload["tags"] = tags    if featured_image_url:        payload["featured_image"] = featured_image_url        r = requests.post(url, headers=headers, json=payload, timeout=30)    data = r.json()        return {        "success": "ID" in data,        "url": data.get("URL"),        "id": data.get("ID")    }

Критическое ограничение: Free WordPress.com предоставляет только posts OAuth scope. Scope media требует платного Business ($25/мес). Загрузка изображений через API на free tier — невозможна. Решение: хостить изображения внешне и ссылаться по URL.

Dev.to

Dev.to — технический хаб. Markdown-нативный, code blocks работают, frontmatter теги авто-категоризуют. URL Dev.to становится ссылкой «опубликовано также на».

# api/publishers/devto.py
import os
import requests
API_KEY = os.environ.get('DEVTO_API_KEY')
def publish_article(title: str, body: str, tags: list, published: bool = False) -> dict:    url = "https://dev.to/api/articles"    headers = {        "api-key": API_KEY,        "Content-Type": "application/json"    }        payload = {        "article": {            "title": title,            "body_markdown": body,            "published": published,            "tags": tags[:4]  # Hard limit Dev.to: максимум 4 тега        }    }        r = requests.post(url, headers=headers, json=payload, timeout=30)    data = r.json()        if r.status_code == 201:        return {"success": True, "url": data.get("url"), "id": data.get("id")}    return {"success": False, "error": data.get("error", f"HTTP {r.status_code}")}

Невидимые лимиты:

  • Максимум 4 тега. Присылать 5 — получить 422.

  • SVG не рендерится. Нужно конвертировать в PNG через FFmpeg перед загрузкой.

  • Режим draft: published: false создаёт черновик в дашборде.

Обе платформы должны завершиться до старта Стадии 3. Если WordPress упал — pipeline останавливается.


Стадия 3: Социальные тизеры — параллельно

После получения URL от WordPress и Dev.to шесть социальных платформ запускаются параллельно.

Bluesky: AT Protocol

Bluesky не использует REST. Использует AT Protocol — бинарные blob-загрузки, создание записей через JSON-RPC, лимит blob 2 МБ.

# api/publishers/bluesky.py
import os
import requests
from datetime import datetime, timezone
HANDLE = os.environ.get('BLUESKY_HANDLE')
APP_PASSWORD = os.environ.get('BLUESKY_APP_PASSWORD')
BASE_URL = "https://bsky.social/xrpc"
def create_session() -> dict:    r = requests.post(        f"{BASE_URL}/com.atproto.server.createSession",        json={"identifier": HANDLE, "password": APP_PASSWORD},        timeout=30    )    data = r.json()    return {        "success": "accessJwt" in data,        "accessJwt": data.get("accessJwt"),        "did": data.get("did")    }
def upload_blob(image_path: str, session: dict) -> dict:    ext = image_path.lower().split(".")[-1]    mime = {"jpg": "image/jpeg", "jpeg": "image/jpeg",             "png": "image/png", "gif": "image/gif"}.get(ext, "image/png")        with open(image_path, "rb") as f:        data = f.read()        r = requests.post(        f"{BASE_URL}/com.atproto.repo.uploadBlob",        headers={            "Authorization": f"Bearer {session['accessJwt']}",            "Content-Type": mime        },        data=data,        timeout=120  # 1.5 МБ загрузки требуют времени    )        blob = r.json().get("blob")    return {"success": bool(blob), "blob": blob}
def post(text: str, image_path: str = None) -> dict:    session = create_session()    if not session["success"]:        return session        now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")        record = {        "$type": "app.bsky.feed.post",        "text": text,        "createdAt": now,    }        if image_path and os.path.exists(image_path):        if os.path.getsize(image_path) > 2_000_000:            compressed = image_path.replace(".png", "-compressed.jpg")            os.system(f"ffmpeg -y -i {image_path} -q:v 2 {compressed}")            image_path = compressed                blob = upload_blob(image_path, session)        if blob["success"]:            record["embed"] = {                "$type": "app.bsky.embed.images",                "images": [{"alt": "", "image": blob["blob"]}]            }        r = requests.post(        f"{BASE_URL}/com.atproto.repo.createRecord",        headers={"Authorization": f"Bearer {session['accessJwt']}"},        json={            "repo": session["did"],            "collection": "app.bsky.feed.post",            "record": record        },        timeout=30    )        data = r.json()    if "uri" in data:        post_id = data["uri"].split("/")[-1]        return {            "success": True,            "url": f"https://bsky.app/profile/{HANDLE}/post/{post\_id}"        }    return {"success": False, "error": data}

Три ловушки:

  1. uploadBlob ожидает сырые байты с заголовком Content-Type — не multipart files={...}.

  2. Таймаут по умолчанию 30 секунд слишком короткий для загрузки 1.5 МБ. Увеличен до 120 секунд после трёх подряд ошибок.

  3. Лимит 300 графем — hard. len(text) считает кодпоинты; японские emoji считаются как несколько графем. Нужен regex.findall(r'\X', text).

Mastodon: двухступенчатая медиа-загрузка

# api/publishers/mastodon.py
import os
import requests
BASE_URL = os.environ.get('MASTODON_URL')
ACCESS_TOKEN = os.environ.get('MASTODON_ACCESS_TOKEN')
def upload_media(image_path: str) -> dict:    url = f"{BASE_URL}/api/v1/media"    headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"}        with open(image_path, "rb") as f:        files = {"file": (os.path.basename(image_path), f)}        r = requests.post(url, headers=headers, files=files, timeout=60)        data = r.json()    return {        "success": "id" in data,        "id": data.get("id"),        "url": data.get("url")    }
def post_status(text: str, media_ids: list = None) -> dict:    url = f"{BASE_URL}/api/v1/statuses"    headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"}    payload = {"status": text}    if media_ids:        payload["media_ids[]"] = media_ids        r = requests.post(url, headers=headers, data=payload, timeout=30)    data = r.json()        return {        "success": "id" in data,        "url": data.get("url")    }

Одним запросом создать статус с текстом и файлом — не работает. Два отдельных запроса обязательны.

Telegram: Bot API

# api/publishers/telegram.py
import os
import requests
BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN')
CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID')
BASE_URL = f"https://api.telegram.org/bot{BOT\_TOKEN}"
def send_message(text: str) -> dict:    url = f"{BASE_URL}/sendMessage"    payload = {        "chat_id": CHAT_ID,        "text": text,        "parse_mode": "HTML",        "disable_web_page_preview": False    }    r = requests.post(url, json=payload, timeout=30)    data = r.json()    return {"success": data.get("ok"), "message_id": data.get("result", {}).get("message_id")}
def send_photo(image_path: str, caption: str = "") -> dict:    url = f"{BASE_URL}/sendPhoto"        with open(image_path, "rb") as f:        files = {"photo": f}        payload = {"chat_id": CHAT_ID, "caption": caption, "parse_mode": "HTML"}        r = requests.post(url, data=payload, files=files, timeout=60)        data = r.json()    return {"success": data.get("ok"), "message_id": data.get("result", {}).get("message_id")}

Особенности Telegram:

  • parse_mode: HTML — поддерживает <b>, <i>, <a>, но не markdown.

  • disable_web_page_preview: False — генерирует превью по голым URL.

  • Фото-загрузка: multipart POST с файлом, не base64.

  • Лимит caption: 1024 символа. Большие статьи — только как текст без картинки.

VK: photos.upload + wall.post

VK API — самый сложный из всех. Требует предварительной загрузки фото на сервер VK, получения owner_id и photo_id, затем прикрепления к записи на стене.

# Получить URL сервера для загрузки
VK_UPLOAD_URL=$(curl -s "https://api.vk.com/method/photos.getWallUploadServer?access\_token=${VK\_TOKEN}&v=5.199" | jq -r '.response.upload_url')
# Загрузить фото на сервер VK
UPLOAD_RESPONSE=$(curl -s -F "photo=@${IMAGE_PATH}" "${VK_UPLOAD_URL}")
# Сохранить фото на стене
SAVE_RESPONSE=$(curl -s "https://api.vk.com/method/photos.saveWallPhoto?server=${SERVER}&photo=${PHOTO}&hash=${HASH}&access\_token=${VK\_TOKEN}&v=5.199")
OWNER_ID=$(echo $SAVE_RESPONSE | jq -r '.response[0].owner_id')
PHOTO_ID=$(echo $SAVE_RESPONSE | jq -r '.response[0].id')
# Опубликовать запись с прикреплённым фото
curl -s "https://api.vk.com/method/wall.post?owner\_id=${OWNER\_ID}&message=${TEXT}&attachments=photo${OWNER\_ID}\_${PHOTO\_ID}&access\_token=${VK\_TOKEN}&v=5.199"

Сложности VK:

  • Токен access_token требует подтверждения прав через OAuth в браузере.

  • Загрузка фото — двухступенчатая: получить upload_url → POST файл → получить server/photo/hash → сохранить через photos.saveWallPhoto.

  • Рекламные блоки: группы с >1000 подписчиков требуют пометку как рекламу. Личная страница — не требует.

  • Rate limit: 3 записи в сутки для личной страницы, 50 для группы.

Tumblr и Paragraph

Следуют аналогичным паттернам — загрузка медиа первым запросом, затем создание поста с media IDs или внешними URL.


Стадия 4: Архивирование и оркестратор

Hermes Agent планирует задачи, но никогда не autopublishes без явного подтверждения.

# scripts/orchestrator.py
import sys
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
sys.path.insert(0, str(Path(__file__).parent.parent))
from api.publishers.bluesky import post as publish_bluesky
from api.publishers.wordpress import publish_post as publish_wordpress
from api.publishers.devto import publish_article as publish_devto
from api.publishers.mastodon import post_status as publish_mastodon, upload_media as upload_mastodon_media
from api.publishers.telegram import send_message as send_telegram, send_photo as send_telegram_photo
from api.publishers.paragraph import publish_post as publish_paragraph
from scripts.adaptation import adapt_for_platform
def run_pipeline(article_path: str, image_path: str, dry_run: bool = True):    with open(article_path) as f:        full_text = f.read()        # Стадия 1: Адаптация    variants = {        "wordpress": adapt_for_platform(full_text, "wordpress"),        "devto": adapt_for_platform(full_text, "devto"),        "bluesky": adapt_for_platform(full_text, "bluesky"),        "mastodon": adapt_for_platform(full_text, "mastodon"),        "telegram": adapt_for_platform(full_text, "telegram"),        "vk": adapt_for_platform(full_text, "vk"),        "tumblr": adapt_for_platform(full_text, "tumblr"),        "paragraph": adapt_for_platform(full_text, "paragraph"),    }        if dry_run:        print("[DRY RUN] Варианты платформ сгенерированы:")        for platform, text in variants.items():            print(f"  {platform}: {len(text)} символов")        return        # Стадия 2: Primary Hub (последовательно)    wp = publish_wordpress({        "title": "Название статьи",        "content": variants["wordpress"],        "status": "publish",        "categories": ["Productivity", "Tools"],        "tags": "automation, python, publishing"    })    canonical_url = wp.get("url")        dev = publish_devto({        "title": "Название статьи",        "body_markdown": variants["devto"],        "published": False,        "tags": ["python", "automation", "publishing"]    })        # Стадия 3: Социальные тизеры (параллельно, 6 потоков)    def publish_bluesky_trailer():        text = f"{variants['bluesky']} → {canonical_url}"        return publish_bluesky(text, image_path)        def publish_mastodon_trailer():        media = upload_mastodon_media(image_path)        text = f"{variants['mastodon']} → {dev.get('url', canonical_url)}"        return publish_mastodon(text, [media["id"]] if media["success"] else [])        def publish_tumblr_post():        return publish_tumblr(image_path, variants["tumblr"], canonical_url)        def publish_paragraph_article():        return publish_paragraph("Название статьи", variants["paragraph"])        def publish_telegram_message():        return send_telegram_photo(image_path, variants["telegram"])        def publish_vk_post():        return publish_vk(image_path, variants["vk"], canonical_url)        with ThreadPoolExecutor(max_workers=6) as executor:        futures = {            "bluesky": executor.submit(publish_bluesky_trailer),            "mastodon": executor.submit(publish_mastodon_trailer),            "tumblr": executor.submit(publish_tumblr_post),            "paragraph": executor.submit(publish_paragraph_article),            "telegram": executor.submit(publish_telegram_message),            "vk": executor.submit(publish_vk_post),        }        social_results = {k: v.result() for k, v in futures.items()}        # Стадия 4: Архивирование    log_publish_results(wp, dev, social_results)    git_commit_with_urls(wp, dev, social_results)    update_llm_wiki_index(wp, dev, social_results)

Human-in-the-loop:

  1. Пользователь говорит «опубликуй эту статью»

  2. Агент генерирует все варианты, показывает превью

  3. Пользователь проверяет Dev.to draft (самый сложный вариант)

  4. Пользователь говорит «подтверждаю» — только тогда выполняются API-запросы

Предотвращает ситуацию «ой, я только что опубликовал черновик». Каждый publisher имеет dry_run=True по умолчанию.


Краевые случаи: реальная учебная программа

Краевой случай

Как обнаружил

Решение

Bluesky blob > 2 МБ

Три подряд ошибки вечером четверга, все 400 без тела

Сжатие FFmpeg: ffmpeg -y -i input.png -q:v 2 output.jpg

WordPress free план блокирует media upload

403 на каждой загрузке изображения

Хостить изображения внешне, ссылаться по URL

Dev.to SVG сломан

Диаграмма неделю показывалась пустым прямоугольником

ffmpeg -y -i diagram.svg diagram.png на стадии адаптации

Dev.to 5-й тег отклонён

Получил 422, прочитал ошибку

Жёсткое ограничение 4 тега. Без переговоров

Mastodon двухступенчатая медиа

Три раза опубликовал текст без картинки

Два отдельных запроса: POST /api/v1/mediaidPOST /api/v1/statuses

Bluesky 300 графем

Пост 305 символов, получил "record too large"

Подсчёт графем через regex.findall(r'\X', text)

Paragraph hotlink-блокировка

GitHub raw URL показывались как битые картинки

VK CDN URL из photos.getById после VK-загрузки

LinkedIn токен истекает

Посты падали с 401 через 60 дней

Ручное обновление. Для ежемесячной публикации автоматизация не стоит затрат

Telegram лимит caption

Статья 1200 символов не влезла в caption к фото

Разделение: фото с коротким caption + отдельное сообщение с полным текстом

VK rate limit

4-я запись в сутки вернула ошибку

Проверка лимита перед публикацией, откладывание на следующий день

Markdown-расхождение

Telegram показывал жирный буквально

Полная очистка markdown для Telegram/VK на стадии адаптации

NotebookLM cookies истекают

Cron задание в 11:00 молча падало на auth

Двухступенчатый cron: 10:30 напоминание → пользователь вставляет cookies → 11:00 публикация


Результаты

Метрика

До pipeline

После pipeline

Время на статью

50 минут (8 платформ × ~6 мин)

90 секунд (автоматизация) + 5 минут проверки

Платформы

3–4 в зависимости от энергии

8 автоматизировано + черновики для 10 дополнительных

Неудачные загрузки

~30% (забыл картинку, неправильный формат, истёкший токен)

~5% (только истечение токена)

SEO-индексация

Нет — социальный контент эфемерен

WordPress как канонический источник, индексируется Google

Переключение контекста

8 вкладок, 8 разных UI, 8 copy-paste

Одна команда, одно превью, одно подтверждение

Версионирование статей

Нет — распределённые копии расходились

Git отслеживает каждый вариант, платформу, URL