Skip to main content
Glama
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`.

Maintenance

ActivityMaintained
ResponsivenessNo issues