Skip to main content
Glama

Threads как API

Личный сервис на VPS, дающий агенту программный доступ к одному аккаунту Threads: публикация, ответы, лайки, подписки, репосты, чтение ленты, профилей и комментариев.

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

Что нужно до начала

  1. VPS с Docker и доменом, направленным на его IP (для TLS).

  2. Приложение Threads: developers.facebook.com → создать приложение → сценарий использования «Threads API». Понадобятся App ID, App Secret и Redirect Callback URL вида https://<домен>/oauth/callback.

  3. Аккаунт Threads, под которым всё будет работать.

Related MCP server: Instagram MCP Server

Настройка

Подробный пошаговый деплой — в docs/DEPLOY-RUNBOOK.md. Ниже сжатая версия.

cp .env.example .env
openssl rand -hex 32   # положи в API_TOKEN

Заполни API_TOKEN, THREADS_APP_ID, THREADS_APP_SECRET, THREADS_REDIRECT_URI, DOMAIN и порты API_BIND_PORT / NOVNC_BIND_PORT.

Все хостовые порты задаются только в .env: оттуда их берёт и docker-compose, и генератор конфига nginx, так что разъехаться они не могут. Контейнеры слушают исключительно 127.0.0.1 — наружу сервис отдаёт nginx.

./deploy/check-ports.sh          # не заняты ли порты соседними сервисами
docker compose up -d --build

sudo htpasswd -cB /etc/nginx/threads-novnc.htpasswd admin   # пароль к noVNC
sudo certbot --nginx -d <домен>
sudo ./deploy/render-nginx.sh --install                     # конфиг из .env

curl https://<домен>/health

Первый запуск: две авторизации

Их именно две, и они независимы.

1. Threads API (для публикации)

Открой в браузере, подставив свой токен:

https://<домен>/oauth/start

Разреши доступ. Тебя вернёт на /oauth/callback, токен сохранится и дальше будет продлеваться сам. Проверка:

curl -H "Authorization: Bearer $API_TOKEN" https://<домен>/actions/get_my_profile -X POST -d '{}'

2. Браузерная сессия (для лайков и ленты)

Chromium не открывает один профиль дважды, поэтому сначала отбираем профиль у сервиса:

curl -X POST -H "Authorization: Bearer $API_TOKEN" https://<домен>/browser/suspend -d '{"minutes": 20}'
docker compose --profile login up -d novnc

Открой https://<домен>/vnc/vnc.html, войди под своим паролем от noVNC, залогинься в Threads в открывшемся окне Chromium — с 2FA, как обычный человек. Затем:

docker compose --profile login down
curl -X POST -H "Authorization: Bearer $API_TOKEN" https://<домен>/browser/resume

Ответ {"loggedIn": true} означает, что браузерные действия готовы.

Логин делается на самом VPS намеренно: IP при входе и при работе совпадает, и сессия живёт заметно дольше, чем перенесённые с ноутбука cookies. Повторять придётся раз в несколько недель, когда Threads разлогинит сессию.

Использование

# Что вообще умеет
curl -H "Authorization: Bearer $API_TOKEN" https://<домен>/actions

# Пост
curl -X POST https://<домен>/post \
  -H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
  -d '{"text": "привет из агента"}'

# Ответ на пост — свой или чужой. Официальный API просит media ID (число), а на
# руках после ленты или поиска обычно ссылка: разворачиваем её браузером.
# Шорткод из ссылки (DcS3AZCjth1) идентификатором не является — Threads на него
# отвечает «Param reply_to_id is not a valid threads_media ID»; resolve_media_id
# принимает и ссылку (url), и шорткод (shortcode).
curl -X POST https://<домен>/actions/resolve_media_id \
  -H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
  -d '{"url": "https://www.threads.com/@someone/post/DcS3AZCjth1"}'
# → {"ok": true, "data": {"url": "...", "media_id": "18100896626255309"}}

# Ответ в конкретную ветку — по media ID комментария (его же отдаёт get_conversation).
curl -X POST https://<домен>/actions/reply_to_post \
  -H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
  -d '{"reply_to_id": "18100896626255309", "text": "и тебе спасибо!"}'

# Ответ под чужим корневым постом Threads разрешает только с правом
# threads_keyword_search. Если API отвечает «Cannot Reply Unless Root Post
# Owner» — токен выдан без него, помогает повторный проход /oauth/start.

# Комментарии под постом — своим или чужим
curl -X POST https://<домен>/actions/read_replies \
  -H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
  -d '{"url": "https://www.threads.net/@someone/post/abc"}'

# Поиск постов
curl -X POST https://<домен>/actions/search_posts \
  -H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
  -d '{"query": "today", "limit": 100, "sort": "recent"}'

# Лайк
curl -X POST https://<домен>/actions/like_post \
  -H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
  -d '{"url": "https://www.threads.net/@someone/post/abc"}'

Ответ всегда одной формы: {"ok": true, "data": …} либо {"ok": false, "error": {"code", "message", "retryable", "retry_after"}}.

Лайк, репост и подписка отвечают парой {"changed", "state"}: changed — сделали ли мы что-то прямо сейчас, state — итоговое состояние. {"changed": false, "state": "liked"} читается однозначно как «лайк уже стоял», а не как «не вышло».

Браузерные действия идут строго по одному: профиль Chromium один на сервис. Звать их параллельно бессмысленно, но и не страшно — запросы становятся в очередь и дожидаются своей очереди целиком, сколько бы ни стояли. Глубина очереди — BROWSER_MAX_QUEUE (20 задач), дольше BROWSER_MAX_QUEUE_WAIT_MS (240 с) задача не стоит: столько запрос всё равно не проживёт у nginx (proxy_read_timeout 300s). Не дождавшуюся отменяем до старта и говорим об этом прямо — с числами, сколько ждали, сколько стояло впереди и сколько занимает одна задача, — чтобы агент не гадал. Выполнить её после отказа значило бы поставить лайк или отправить ответ, о котором клиенту уже не сообщили.

Таймаут своего MCP-клиента подними до тех же 300 с: по умолчанию он ждёт 60 с и обрывает запрос раньше, чем очередь дойдёт до задачи, — агент видит «не смог достучаться до сервера», хотя сервис в этот момент спокойно доделывает работу и пишет в журнал успех. В Claude Code это MCP_TOOL_TIMEOUT=300000 (и MCP_TIMEOUT для запуска stdio-сервера) в окружении агента.

Коды ошибок: NEEDS_LOGIN (нужен ручной вход), RATE_LIMITED, UPSTREAM, SELECTOR_MISS (Threads изменил вёрстку — в var/failures/ лежит скриншот), INVALID_INPUT.

UPSTREAM у браузерных действий означает, что Threads вместо содержимого показал свою заглушку «Что-то пошло не так» с кнопкой «Повторить»: страница загрузилась, сессия жива, вёрстка цела — не приехало только содержимое. Мы перезагружаем страницу трижды с растущей паузой и лишь потом отдаём ошибку, уже как повторяемую (retryable: true). Чинить селекторы в этом случае нечего: снимок из var/failures/upstream-*.png показывает ровно эту заглушку.

Подключение к агенту

Сервис сам по себе MCP-сервер, доступный по сети: транспорт Streamable HTTP на POST /mcp, авторизация тем же бирер-токеном, что и у остального API. Агент подключается откуда угодно — с ноутбука, из другого облака, — ему нужны только адрес и токен.

{
  "mcpServers": {
    "threads": {
      "type": "http",
      "url": "https://твой-домен/mcp",
      "headers": { "Authorization": "Bearer ТОКЕН" }
    }
  }
}

В Claude Code то же самое одной командой:

claude mcp add --transport http threads https://твой-домен/mcp \
  --header "Authorization: Bearer ТОКЕН"

Инструментами агенту сейчас отдаются только браузерные действия (лайки, подписки, репосты, чтение ленты и профилей, поиск, resolve_media_id, check_login). Действия через официальный Graph API Meta — publish_post, reply_to_post, list_my_posts, list_my_replies, get_conversation, delete_post, get_quota, get_my_profile — временно скрыты из списка инструментов, но по HTTP (POST /actions/<name>) работают как раньше. Вернуть их агенту — убрать проверку EXPOSED_DRIVERS в src/mcp/server.ts.

Сессий сервер не держит: на каждый запрос поднимается свой экземпляр, заголовок mcp-session-id не выдаётся и не нужен. Отвечает обычным JSON, без SSE, — так проще проходить через прокси. Действия выполняются прямо в процессе сервиса, тем же браузером и той же очередью, что и обычные HTTP-запросы.

Проверить доступ без агента:

curl -s -X POST https://твой-домен/mcp \
  -H "Authorization: Bearer ТОКЕН" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Локальный вариант — тот же MCP по stdio, если агент живёт на этом же сервере:

{
  "mcpServers": {
    "threads": {
      "command": "docker",
      "args": ["exec", "-i", "threads-api-api-1", "node", "dist/src/mcp/main.js"]
    }
  }
}

Переменные окружения передавать не нужно: контейнер уже поднят с ними. Действия этот процесс не выполняет сам, а зовёт POST /actions/:action у сервиса — адрес берётся из PORT, переопределяется переменной THREADS_API_URL.

Оба транспорта работают с одним реестром действий, расхождений между ними быть не может. И оба намеренно не открывают браузер сами: Chromium не рассчитан на два процесса с одной папкой профиля, а сервис держит браузер открытым постоянно. Второй Chromium на том же профиле рано или поздно побил бы cookies — сессия Threads слетела бы, и логиниться пришлось бы заново руками через noVNC. Пока браузер один, POST /browser/suspend освобождает профиль целиком, а несколько агентов делят общую очередь и общие суточные лимиты.

Сбор датасета

Разбор карточки проверен на живой выдаче Threads 19 августа 2026 года: текст поста приезжает без ника, времени и счётчиков, ноль лайков отличается от «счётчик не прочитан», картинка поста не путается с аватаром автора, а кадр-превью видео не считается отдельной картинкой. Ссылки приходят без трекинговых параметров, так что дедуп по ним работает.

Выборка задаётся английскими словами, но в выдачу регулярно попадают посты на других языках — они не выбрасываются, а помечаются колонкой lang: ru, если в тексте есть кириллица, en для латиницы, other для всего прочего (хинди, каннада, арабица и так далее). Это метка по алфавиту, а не определение языка: португальский и индонезийский попадут в en. Для «отделить русское от остального» этого хватает, для настоящей языковой статистики — нет.

Подпись кнопки перевода, которую Threads приклеивал к тексту неанглийских постов, отрезается.

Помни про риск: прогон на настоящем аккаунте расходует суточную квоту и нарушает условия Meta, так что первый запуск стоит делать глазами.

Скрипт scripts/collect.ts копит посты в отдельную var/collect.sqlite: один заход в ленту плюс поиск по частотным английским словам из src/collect/queries-en.txt.

npm run collect -- --target 1000      # один прогон; сколько он идёт, замерь сам
npm run collect -- --export dataset.csv

Прогон на тысячу постов идёт долго, поэтому для него есть обёртка, которая отвязывает сборщик от терминала — разрыв ssh его не убьёт:

./scripts/collect-background.sh 1000     # запустить и отвязать от терминала
./scripts/collect-background.sh status   # работает ли и что в логе
./scripts/collect-background.sh stop     # остановить прогон
tail -f var/collect-*.log

Запускается не сам сборщик, а присмотрщик: сборщик держит удар внутри прогона, но от падения процесса — убитый браузер, севший сервис — защититься не может. Присмотрщик поднимает упавший заход до пяти раз с паузой в пять минут, вычитая уже собранное из остатка, и не перезапускает то, что перезапуском не лечится (rate_limited, queries_exhausted, NEEDS_LOGIN, SELECTOR_MISS, UNAUTHORIZED). Пределы меняются переменными MAX_RESTARTS и RESTART_PAUSE. stop бьёт сигналом по всей группе процессов, так что сборщик не остаётся сиротой.

Она же не даст запустить второй сборщик поверх первого: два прогона разом тратят двойную квоту ради одних и тех же постов.

Регулярный сбор повешен на крон — трижды в день по 300 постов:

# collect-cron:begin
0 3 * * * /var/www/apps/remote_browser/scripts/collect-cron.sh 300
0 13 * * * /var/www/apps/remote_browser/scripts/collect-cron.sh 300
0 23 * * * /var/www/apps/remote_browser/scripts/collect-cron.sh 300
0 6 19 9 * /var/www/apps/remote_browser/scripts/collect-cron-disable.sh >> …/var/cron.log 2>&1
# collect-cron:end

Время в кроне — UTC, и часы выбраны по чужим суткам, а не по нашим. Поиск отдаёт почти живую ленту: между публикацией поста и его сбором проходит в среднем полчаса (p90 — 13 часов), так что каждый прогон снимает примерно один текущий час мировых суток, и час запуска решает, чью ленту мы черпаем. Слова английские, поэтому три захода расставлены по вечерам англоязычных стран:

UTC

где вечер

03:00

20:00 PT — Западное побережье США

13:00

14:00 BST и 18:30 IST — день в UK, вечер в Индии и на Филиппинах

23:00

19:00 ET — Восточное побережье США

Промежуток 15-19 UTC не занят намеренно: это московский вечер, и выдача оттуда приходит русскоязычная. В первом датасете, собранном по старому расписанию, из 3223 постов 536 оказались русскими, а у захода в 18:00 UTC русских было 77%.

Зовётся не collect-background.sh напрямую, а обёртка: у крона нет ни нашего PATH (node стоит через nvm), ни рабочей папки, а вывод надо куда-то деть — обёртка чинит всё три и пишет в var/cron.log. Туда же попадает отказ стартовать, поэтому проверять прогон надо там, а не в почте крона.

Стартует прогон не ровно в назначенную минуту: обёртка сначала спит случайные 0-30 минут (в секундах, чтобы не попадать и на круглую минуту) — ежедневный запуск секунда в секунду выглядит роботом, каким он и является. Ручной проверке ждать незачем: MAX_DELAY=0 ./scripts/collect-cron.sh 300.

Сбор поставлен на 30 дней. Последняя строка блока — самоснос: 19 сентября 2026 в 06:00 UTC collect-cron-disable.sh вырежет из crontab весь блок между метками collect-cron:begin и collect-cron:end, включая себя. Снять руками раньше — тот же скрипт; он идемпотентен и чужих заданий не трогает. Продлить — переставить дату в строке самосноса.

Три раза по 300 постов — это около девяти поисков в день при суточном пределе в 30 (LIMIT_SEARCHES_PER_DAY), запас на ручные прогоны остаётся. Короткие заходы вместо двух длинных: между прогонами проходит несколько часов, и слова успевают набрать свежую выдачу, а не отдавать ту же, что дедуп уже выбросил. Если прошлый прогон к следующему сроку ещё идёт, новый упрётся в замок и тихо уйдёт — это норма, а не сбой.

В логе видно, где прогон находится: у каждой строки метка времени, перед запросом пишется слово и счёт ([7/85] ищу «about» — собрано 140/300, …), а пока ответа нет, раз в полминуты идёт отметка «всё ещё жду».

Сборщик рассчитан на то, что браузер на той стороне иногда ложится. Временную ошибку (INTERNAL, UPSTREAM, обрыв запроса) он повторяет трижды с паузами 30/90/180 секунд, а после пяти неудачных слов подряд уходит на десятиминутную передышку и продолжает ротацию; сдаётся только если три передышки подряд не принесли ни одного успешного слова. Неудачный поиск суточную квоту не тратит — сервис считает только успешные, — поэтому упорство стоит лишь времени. Ожиданием не лечатся NEEDS_LOGIN, SELECTOR_MISS и UNAUTHORIZED: на них прогон останавливается сразу.

Браузер работает в настоящем окне на виртуальном экране, а не в headless-режиме. Дело не в картинке: при headless: true Playwright запускает не Chromium, а урезанный chromium_headless_shell, и тот сам пишет о себе «HeadlessChrome» в User-Agent и в Client Hints, не отдаёт window.chrome и показывает пустой список плагинов. Под Xvfb те же страницы открывает обычный Chromium, который представляется Chrome/151 без пометок об автоматизации. Экран поднимает deploy/start.sh (он же CMD образа), режим переключается BROWSER_HEADED. Цена — память: полный Chromium тяжелее, поэтому лимит контейнера поднят с 2 до 3 ГиБ.

Скролл намеренно неровный. Круг прокрутки — не одно событие на 2000 пикселей через ровные 1200 мс, а серия щелчков колеса по 90-170 пикселей с паузами 25-90 мс, затухающим хвостом, изредка движением курсора и паузой 1.8-3.5 с после серии (в 15% случаев — 6-15 с, «засмотрелся»). Все значения случайны с точностью до миллисекунды, форма круга — planScroll в src/browser/scroll.ts, она же покрыта тестами. Сотня постов из ленты собирается за ~90 с вместо ~38 с: скорость разменяна на непохожесть на метроном осознанно. Гарантий это не даёт, и главный риск — объём запросов и датацентровый адрес, а не форма прокрутки.

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

Датасет смещён, и это важно при его чтении: поиск отбирает посты со словом из английского списка, лента — персональные рекомендации аккаунта. Столбец source позволяет разделить два потока или сравнить их, столбец lang — отсечь неанглийское.

Лимиты

Официальный API: 250 постов, 1000 ответов, 100 удалений в сутки — их считает Threads, текущее потребление показывает действие get_quota.

Браузерные действия ограничены нашими собственными счётчиками (по умолчанию 150 лайков, 50 подписок, 30 репостов в сутки) и разделены случайными паузами. Лимиты намеренно консервативные.

Поиск ограничен отдельно — 30 запросов в сутки (LIMIT_SEARCHES_PER_DAY). Одного прогона сборщика это не стесняет, но зациклившийся скрипт остановит.

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

Разработка

npm install
npm test          # 105 тестов, живой аккаунт не нужен
npm run typecheck
npm run dev

Браузерные действия тестами не покрываются: они зависят от живой вёрстки Threads. Все зацепки за DOM собраны в src/browser/selectors.ts — при редизайне правится один файл.

Дизайн и обоснование решений: docs/plans/2026-08-17-threads-api-design.md.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI agents to interact with Twitter/X through Playwright browser automation without requiring an official API key. It provides tools for posting content, searching tweets, reading feeds, and managing social interactions like follows and likes.
    4
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    Enables AI agents to interact with X (Twitter) through its private GraphQL API using session cookies, supporting tweets, replies, likes, retweets, searches, direct messages, and more. Provides 24 MCP tools for full account automation.
    24
    1,260 npm
    3
    MIT
  • A
    license
    A
    quality
    D
    maintenance
    Enables AI agents to read, post, search, and engage with Twitter/X content, including AI-powered reply drafting and analytics.
    17
    MIT