В дашборде было 83 успешных engagement’а. В аналитике X — 16 настоящих ответов. Пять к одному. Неделю я этого не замечал.

Контекст: у меня автономный AI-агент, который пишет комментарии в X (Twitter) от имени клиентов. Находит релевантные треды, генерирует ответ, публикует через браузер. Без моего участия. Ну, предполагается что без моего участия.

Как устроена публикация

Агент управляет реальным браузером через Puppeteer CDP. Не API (у X его считай нет для постинга), а именно браузер, как человек. Открывает тред, набирает текст, нажимает Reply.

Функция clickReplyButton() находит кнопку, вызывает .click(), возвращает true если DOM-событие сработало:

// Упрощённо — как выглядела первая версия
const button = await page.$('[data-testid="tweetButton"]');
if (button) {  
  const disabled = await button.evaluate(el =>     
     el.getAttribute('aria-disabled') === 'true'  
  );  
  if (!disabled) {    
    await button.click();
    return true; // <-- вот тут проблема  
  }
}
return false;

true означает: DOM-элемент найден, не disabled, .click() выполнен. Всё. Больше ничего.

Агент записывал каждый true как успешный engagement в базу. 83 записи. Дашборд зелёный.

Почему X молча дропает контент

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

А на стороне X — спам-фильтр, rate limiter, или просто “этот аккаунт слишком активен”, и контент тихо пропадает.

Причём это логично. Если бот получает ошибку, он адаптируется. Если бот думает что всё получилось, он продолжает слать в пустоту. X выбрал второе.

Вторая проблема: фейковые ID

Каждый опубликованный ответ должен получить свой ID, чтобы потом трекать метрики. Агент вытаскивал ID из URL страницы после публикации:

// page.url() = "https://x.com/user/status/1234567890"
const tweetId = page.url().split('/status/')[1];

Только X не редиректит на страницу ответа после публикации. URL остаётся тот же — страница родительского твита. В итоге our_reply_id === parent_tweet_id для каждой записи. 83 записи с чужими ID. И никто не заметил, потому что формат-то правильный — это валидный tweet ID, просто не тот.

Как я это обнаружил

Не агент нашёл. Не мониторинг. Я сам зашёл в аналитику X через неделю и увидел что цифры не сходятся.

Пять к одному. Неделя работы. Ноль алертов.

В чём корень

DOM-событие — это один уровень. Платформа — другой. Между ними чёрный ящик (спам-фильтр, rate limiter, content policy), и он работает тихо.

DOM: button.click() → success ✓
Browser: event fired → success ✓
X Platform: content dropped → ??? (тишина)
Database: recorded as "posted" → success ✓
Dashboard: 83 engagements → всё отлично ✓
Reality: 16 actual posts

Мой мониторинг заканчивался на DOM. Всё что глубже — terra incognita.

Фикс: верификация после каждого ответа

Теперь после Reply агент не просто записывает “успех”. Он перезагружает страницу, ищет свой текст в треде и вытаскивает реальный ID из DOM:

export async function verifyReplyPosted(  
  page: Page,  
  replyText: string,  
  parentTweetId: string,
): Promise<{ verified: boolean; replyTweetId: string | null }> {
  
  // Ждём 3 секунды, перезагружаем страницу  
  await new Promise(r => setTimeout(r, 3000));  
  await page.reload({ waitUntil: "domcontentloaded" });  
  await new Promise(r => setTimeout(r, 5000));
  
  // Скроллим вниз чтобы загрузить тред  
  await page.evaluate("window.scrollBy(0, 1000)");  
  await new Promise(r => setTimeout(r, 2000));
  
  // Сканируем все article-элементы в треде  
  const result = await page.evaluate(`    
     (function() {      
         var snippet = '${safe}'; // первые 30 символов нашего ответа      
         var articles = document.querySelectorAll(        
           'article[data-testid="tweet"]'      
         );
         for (var i = 0; i < articles.length; i++) {        
             var textEl = articles[i].querySelector(          
                 '[data-testid="tweetText"]'        
             );        

             if (!textEl) continue;        
             var text = (textEl.innerText || '').slice(0, 60);

             // Ищем наш текст среди ответов        
             if (text.indexOf(snippet.slice(0, 30)) !== -1) {

                 // Нашли — извлекаем РЕАЛЬНЫЙ ID из DOM-ссылок          
                 var links = articles[i].querySelectorAll(            
                   'a[href*="/status/"]'          
                 );          

                 // ... извлечение ID из href, не из page.url()          
                 return { verified: true, replyTweetId: realId };        
               }      
         }            

         return { verified: false, replyTweetId: null };    
     })()  
 `);    
  return result;
}

Перезагрузка заставляет X отрендерить актуальный тред, не кэш. Если наш текст есть среди article-элементов — X его принял. ID берём из href ссылок внутри article, а не из page.url().

Если verified: false — запись в базу не создаётся. Нет пруфа, нет engagement’а.

Мораль, если нужна

Проблема не специфична для X. Любая платформа имеет право молча дропнуть ваш контент. Спам-фильтры и rate limiters работают тихо специально, потому что шумные фильтры учат ботов их обходить.

Единственный надёжный способ проверить что действие реально выполнено — посмотреть на результат глазами пользователя. Не “кнопка нажалась”, не “запрос ушёл”, а “контент появился на странице, и у него есть свой ID”. Это работает для постинга в соцсети, для отправки формы, для оплаты, для деплоя. submit !== accepted, CI green !== сервис работает.

Если ваша проверка заканчивается на “событие сработало” — вы не мониторите. Вы записываете в базу свои надежды.

Текущие цифры

После внедрения верификации, verified success rate ~94%. Остальные 6% — X дропает, и теперь я об этом знаю. В реальном времени, а не через неделю.


Пишу апдейты с сырыми цифрами, включая когда что-то ломается: @malakhovdm