threads
README.md
# 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, под которым всё будет работать.
## Настройка
Подробный пошаговый деплой — в [`docs/DEPLOY-RUNBOOK.md`](docs/DEPLOY-RUNBOOK.md).
Ниже сжатая версия.
```bash
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.
```bash
./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`, токен сохранится и дальше будет
продлеваться сам. Проверка:
```bash
curl -H "Authorization: Bearer $API_TOKEN" https://<домен>/actions/get_my_profile -X POST -d '{}'
```
### 2. Браузерная сессия (для лайков и ленты)
Chromium не открывает один профиль дважды, поэтому сначала отбираем профиль у сервиса:
```bash
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, как обычный человек. Затем:
```bash
docker compose --profile login down
curl -X POST -H "Authorization: Bearer $API_TOKEN" https://<домен>/browser/resume
```
Ответ `{"loggedIn": true}` означает, что браузерные действия готовы.
Логин делается на самом VPS намеренно: IP при входе и при работе совпадает, и сессия
живёт заметно дольше, чем перенесённые с ноутбука cookies. Повторять придётся раз в
несколько недель, когда Threads разлогинит сессию.
## Использование
```bash
# Что вообще умеет
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. Агент
подключается откуда угодно — с ноутбука, из другого облака, — ему нужны только
адрес и токен.
```json
{
"mcpServers": {
"threads": {
"type": "http",
"url": "https://твой-домен/mcp",
"headers": { "Authorization": "Bearer ТОКЕН" }
}
}
}
```
В Claude Code то же самое одной командой:
```bash
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-запросы.
Проверить доступ без агента:
```bash
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, если агент живёт на этом же сервере:
```json
{
"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`.
```bash
npm run collect -- --target 1000 # один прогон; сколько он идёт, замерь сам
npm run collect -- --export dataset.csv
```
Прогон на тысячу постов идёт долго, поэтому для него есть обёртка, которая
отвязывает сборщик от терминала — разрыв ssh его не убьёт:
```bash
./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 постов:
```cron
# 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
этой проблемы не имеют.
## Разработка
```bash
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`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues