Парсер Whatsapp с AI обработкой

Мы — команда Datacol, занимаемся созданием парсеров уже более 15 лет. Неоднократно создавали для наших клиентов парсера Whatsapp. Понимаем, что возможно вы захотите самостоятельно создать такой парсер, используя инструменты для вайб-кодинга. Эта статья поможет вам создать такой парсер. Но если вы не хотите разбираться в нюансах разработки, обратитесь к нам, чтобы мы создали пасрер Whatsapp под ваши требования или отправили вам готовый парсер для тестирования.

Типичный сценарий: в WhatsApp десятки каналов и групп, где организаторы постят анонсы концертов, выставок, митапов и фестивалей. Всё это разбросано по разным чатам, написано в произвольном формате и теряется среди обычного трёпа. Задача — автоматически собирать эти сообщения, структурировать их через OpenAI и публиковать в единую базу городского сайта ивентов. В этой статье — все технические нюансы, на которые потеряешь время, если не знать заранее.

1. Фундаментальное ограничение: нет официального API

WhatsApp не предоставляет публичного API для личных аккаунтов. WhatsApp Business Cloud API существует, но работает только для бизнес-аккаунтов и не позволяет читать чужие каналы — только получать входящие сообщения от пользователей, которые сами написали боту.

Для чтения публичных каналов и групп единственный практичный путь — автоматизация WhatsApp Web через библиотеку whatsapp-web.js. Она запускает headless Chromium (Puppeteer) и общается с внутренним JS API WhatsApp Web через WebSocket.

Важно: whatsapp-web.js — это не официальная интеграция. Библиотека реверс-инженерит внутренний протокол WhatsApp Web. Meta периодически меняет внутреннее API, что ломает библиотеку. Закладывайте время на обновления и следите за issues в репозитории.

2. Каналы vs Группы: принципиальная разница

В WhatsApp есть два типа объектов, где публикуются ивенты — и у них разная механика парсинга:

Параметр Группы (Groups) Каналы (Channels / Newsletters)
Тип объекта chat.isGroup === true chat.isChannel === true
Доступность Нужно быть участником группы Нужно быть подписчиком канала
Получение сообщений fetchMessages() работает Поддержка через chat.fetchMessages(), но экспериментальная
Риск бана Средний (вы участник, читаете как обычно) Низкий (подписчики не отслеживаются)
Событие real-time client.on(‘message’) client.on(‘message’) (работает нестабильно)

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

3. Инициализация клиента и стратегия аутентификации

Базовая настройка клиента с LocalAuth для сохранения сессии:

const { Client, LocalAuth } = require('whatsapp-web.js');

const client = new Client({
  authStrategy: new LocalAuth({
    dataPath: process.env.APPDATA
      ? `${process.env.APPDATA}/whatsapp-events-scraper`
      : `${process.env.HOME}/.whatsapp-events-scraper`
  }),
  puppeteer: {
    headless: true,
    args: [
      '--no-sandbox',           // обязательно для Linux/Docker
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage' // важно при ограниченном /dev/shm в контейнерах
    ]
  }
});

Нюансы аутентификации

  • Первый запуск — в терминале генерируется QR-код. Нужно отсканировать его с телефона через WhatsApp — Связанные устройства.
  • Персистентность сессии — LocalAuth сохраняет профиль Chromium и ключи шифрования в указанной папке. Не удаляйте её между запусками.
  • Истечение сессии — сессия слетает, если телефон долго без интернета или если кто-то вручную разлогинил устройство. Нужен мониторинг и алерты.
  • Docker/VPS — папку с сессией монтируйте как volume, иначе каждый перезапуск контейнера требует повторного сканирования QR.
// Отображение QR в терминале
const qrcode = require('qrcode-terminal');
client.on('qr', qr => {
  qrcode.generate(qr, { small: true });
  console.log('Отсканируйте QR-код через WhatsApp на телефоне');
});

client.on('authenticated', () => console.log('Сессия восстановлена'));
client.on('auth_failure', () => console.error('Сессия истекла - нужен новый QR'));
client.on('ready', () => console.log('Клиент готов к работе'));

4. Два режима работы парсера WhatsApp

Режим A: Real-time слушатель

Процесс остаётся запущенным постоянно и реагирует на новые сообщения по мере их появления. Оптимально для продакшена.

client.on('message', async (msg) => {
  const chat = await msg.getChat();

  // Фильтр: только нужные группы/каналы
  if (!isTargetChat(chat)) return;

  // Фильтр: только текстовые сообщения (не стикеры, не войсы)
  if (msg.type !== 'chat') return;

  await processMessage(msg.body, chat.name, msg.timestamp);
});

Режим B: Пакетное извлечение по расписанию

Запускается по cron или Task Scheduler, забирает сообщения за период с последнего запуска и завершается.

client.on('ready', async () => {
  const lastCheckTime = await getLastCheckTime(); // из JSON или БД
  const currentTime = Date.now();

  const chats = await client.getChats();
  const targets = chats.filter(c =>
    (c.isGroup || c.isChannel) && isTargetChat(c)
  );

  for (const chat of targets) {
    // КРИТИЧНО: лимит ~100 сообщений - нет пагинации в API
    const messages = await chat.fetchMessages({ limit: 100 });

    const fresh = messages.filter(m => {
      const msgMs = m.timestamp * 1000; // WhatsApp timestamp в секундах!
      return msgMs >= lastCheckTime && msgMs <= currentTime;
    });

    for (const msg of fresh) {
      if (msg.type !== 'chat') continue;
      await processMessage(msg.body, chat.name, msg.timestamp);
      await sleep(800); // пауза между запросами
    }
  }

  await saveLastCheckTime(currentTime);
  await client.destroy();
  process.exit(0);
});

Жёсткое ограничение: fetchMessages({ limit: N }) возвращает максимум последние N сообщений из кэша WhatsApp Web. Нет пагинации, нет доступа к истории старше загруженного кэша. Для ивент-парсера это обычно не критично — анонсы актуальны в горизонте нескольких дней, но уйти глубже в историю не получится.

5. Фильтрация чатов по названию

Названия групп меняются, поэтому жёсткое совпадение ненадёжно. Используйте частичное совпадение по ключевым словам:

const TARGET_KEYWORDS = [
  'ивент', 'event', 'афиша', 'анонс',
  'концерт', 'выставка', 'митап', 'тбилиси events',
  'что делать', 'куда пойти'
];

function isTargetChat(chat) {
  if (!chat.isGroup && !chat.isChannel) return false;
  const name = chat.name.toLowerCase();
  return TARGET_KEYWORDS.some(kw => name.includes(kw.toLowerCase()));
}

Дополнительно можно вести whitelist конкретных ID чатов — они стабильны даже при переименовании группы:

// chat.id.user возвращает стабильный идентификатор
const WHITELISTED_IDS = ['120363xxxxxx@g.us', '120363yyyyyy@g.us'];

function isTargetChat(chat) {
  if (WHITELISTED_IDS.includes(chat.id._serialized)) return true;
  // ... fallback на keyword matching
}

6. Слой AI: OpenAI как классификатор и структуратор

Сырые сообщения из WhatsApp — это хаос: смесь анонсов, обсуждений, репостов, рекламы и флуда. OpenAI решает две задачи одновременно — определяет, является ли сообщение ивентом, и если да — извлекает структурированные данные.

const systemPrompt = `
Ты - парсер городских мероприятий. Анализируй сообщения из WhatsApp групп.

Сегодняшняя дата: ${new Date().toLocaleDateString('ru-RU')}.

Если сообщение является анонсом конкретного единичного события - верни JSON:
{
  "is_event": true,
  "title": "Название события",
  "date": "YYYY-MM-DD",
  "time": "HH:MM или null",
  "location": "Место проведения или null",
  "description": "Краткое описание 1-2 предложения",
  "category": "concert|exhibition|meetup|sport|festival|other",
  "price": "бесплатно / 500 руб / null",
  "contact": "ссылка или телефон или null"
}

Если сообщение НЕ является анонсом события - верни:
{ "is_event": false }

Отклоняй:
- Обычные разговоры и обсуждения
- Рекламу товаров/услуг без конкретного события
- Расписания с множеством дат (серии занятий, еженедельные встречи)
- Новости без привязки к мероприятию
- Репосты без новой информации

Отвечай ТОЛЬКО валидным JSON без пояснений и markdown.
`;

async function classifyMessage(text) {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    max_tokens: 300,
    temperature: 0.2, // низкая температура = детерминированная классификация
    response_format: { type: 'json_object' },
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: text.slice(0, 2000) } // ограничение длины
    ]
  });

  return JSON.parse(response.choices[0].message.content);
}

Ключевые решения в промпте

  • Передача текущей даты — без этого «завтра» и «в эту субботу» не разрешаются в конкретные даты.
  • Явный список отклонений — перечисление того, что НЕ является ивентом, снижает false positive.
  • JSON mode (response_format: { type: ‘json_object’ }) — гарантирует валидный JSON на выходе без markdown-обёртки.
  • Температура 0.2 — классификация должна быть стабильной, а не творческой.
  • gpt-4o-mini — достаточно мощный для задачи классификации при стоимости на порядок ниже GPT-4o.

7. Дедупликация: одно событие — много репостов

Анонс одного концерта может появиться в 5 разных группах и 3 раза в одной группе (анонс, напоминание, «сегодня!»). Нужна дедупликация на двух уровнях:

Уровень 1: Схожесть строк (до вызова OpenAI)

const stringSimilarity = require('string-similarity');

async function isDuplicate(newText, existingTexts) {
  for (const existing of existingTexts) {
    const score = stringSimilarity.compareTwoStrings(
      newText.toLowerCase().trim(),
      existing.toLowerCase().trim()
    );
    if (score >= 0.72) return true;
  }
  return false;
}

// Загружаем тексты уже сохранённых ивентов за ±3 дня
// Нет смысла сравнивать с ивентами прошлого месяца

Уровень 2: Семантическое совпадение через OpenAI

Для случаев, когда один ивент описан разными словами (например, русский и английский варианты анонса):

// После классификации: сравниваем структурированные данные
function isSameEvent(eventA, eventB) {
  // Совпадение даты + локации = высокая вероятность дубля
  if (eventA.date === eventB.date && eventA.location === eventB.location) {
    const titleScore = stringSimilarity.compareTwoStrings(
      eventA.title.toLowerCase(),
      eventB.title.toLowerCase()
    );
    if (titleScore >= 0.5) return true;
  }
  return false;
}

8. Хранение данных: Supabase как бэкенд

Supabase (PostgreSQL) удобен для этого стека — есть встроенный REST API, который можно использовать прямо из Node.js без ORM, и real-time подписки для фронтенда сайта ивентов.

-- Схема таблицы ивентов
CREATE TABLE events (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title       TEXT NOT NULL,
  date        DATE NOT NULL,
  time        TIME,
  location    TEXT,
  description TEXT,
  category    TEXT,
  price       TEXT,
  contact     TEXT,
  source_chat TEXT,        -- название WhatsApp группы
  raw_text    TEXT,        -- исходное сообщение для отладки
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  is_approved BOOLEAN DEFAULT false -- модерация перед публикацией
);
// Вставка ивента
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY);

async function saveEvent(eventData, sourceChat, rawText) {
  const { error } = await supabase.from('events').insert({
    ...eventData,
    source_chat: sourceChat,
    raw_text: rawText,
    is_approved: false // всегда идёт на ревью сначала
  });
  if (error) console.error('Supabase error:', error);
}

Архитектурный совет: добавьте поле is_approved и не публикуйте ивенты автоматически без модерации. OpenAI ошибается — особенно с нестандартными форматами дат («в ближайшую пятницу») и адресами. Простая admin-панель с кнопками «одобрить / отклонить» спасёт от публикации мусора на сайте.

9. Обработка временных меток

Классический источник багов: WhatsApp возвращает Unix timestamp в секундах, JavaScript работает с миллисекундами.

// НЕПРАВИЛЬНО - сравнение в разных единицах
if (msg.timestamp >= lastCheckTime) { ... } // lastCheckTime в мс, msg.timestamp в сек

// ПРАВИЛЬНО
const msgTimeMs = msg.timestamp * 1000;
if (msgTimeMs >= lastCheckTime && msgTimeMs <= currentTime) { ... }

// Форматирование для отображения
const date = new Date(msg.timestamp * 1000).toLocaleString('ru-RU', {
  timeZone: 'Asia/Tbilisi' // или нужный timezone города
});

Отдельный нюанс — таймзона. Если парсер запущен на сервере в UTC, а события происходят в другом городе, даты могут съезжать на сутки. Всегда указывайте timezone явно и в системном промпте для OpenAI, и при сохранении в БД.

10. Rate Limiting и защита от бана

WhatsApp детектирует автоматизацию по частоте и паттернам API-вызовов. Ключевые меры:

  • Паузы между вызовами — минимум 0.5-1 секунда между fetchMessages() для разных групп.
  • Только чтение — не отправляйте автоматические сообщения в ответ. Любая автоотправка многократно повышает риск бана.
  • Реалистичные паттерны — не опрашивайте 50 групп за 10 секунд. Добавляйте случайный jitter к задержкам.
  • Один номер — один парсер — номер телефона, привязанный к парсеру, несёт риск. Используйте отдельный номер, не основной рабочий.
  • Телефон должен быть онлайн — WhatsApp Web перестаёт работать, если телефон с привязанным аккаунтом отключается от интернета дольше чем на несколько часов.
const sleep = ms => new Promise(r => setTimeout(r, ms));
const jitter = () => Math.floor(Math.random() * 500); // 0-500мс случайная добавка

for (const chat of targetChats) {
  await processChat(chat);
  await sleep(1000 + jitter()); // 1.0 - 1.5 сек между группами
}

11. Персистентность состояния между запусками

Для пакетного режима по расписанию нужно хранить время последней проверки, чтобы не обрабатывать одни и те же сообщения дважды:

const fs = require('fs');
const path = require('path');

const STATE_FILE = path.join(
  process.env.APPDATA || process.env.HOME,
  'whatsapp-events-scraper',
  'runtime.json'
);

async function getLastCheckTime() {
  try {
    const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
    return data.lastCheckTime;
  } catch {
    // Первый запуск - берём последние 24 часа
    return Date.now() - 24 * 60 * 60 * 1000;
  }
}

async function saveLastCheckTime(time) {
  fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
  fs.writeFileSync(STATE_FILE, JSON.stringify({
    lastCheckTime: time,
    lastCheckTimeFormatted: new Date(time).toISOString()
  }));
}

Не храните state-файл в директории скрипта — при запуске через Task Scheduler или systemd без прав на запись в рабочую директорию файл не создастся. Используйте %APPDATA% на Windows или $HOME на Linux.

12. Уведомления о новых ивентах: Telegram-бот

Добавьте Telegram-бот для мониторинга — он уведомляет команду о каждом найденном ивенте, ожидающем одобрения:

async function notifyTelegram(event, sourceChat) {
  const text = `
🎉 *Новый ивент найден*
📍 Источник: ${sourceChat}

*${event.title}*
📅 ${event.date}${event.time ? ' в ' + event.time : ''}
📍 ${event.location || 'место не указано'}
💰 ${event.price || 'цена не указана'}

${event.description}

[Открыть в панели модерации](${process.env.ADMIN_URL}/events/pending)
`.trim();

  await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      chat_id: process.env.TELEGRAM_CHAT_ID,
      text,
      parse_mode: 'Markdown'
    })
  });
}

13. Проблема Puppeteer/Chromium в продакшене

whatsapp-web.js при установке скачивает Chromium (~150-170MB). Это создаёт ряд проблем:

  • Первая установка — npm install долгий, особенно на слабом VPS.
  • Память — headless Chrome потребляет 200-400MB RAM. На VPS с 512MB это проблема.
  • Linux-сервер — обязательны флаги —no-sandbox и —disable-setuid-sandbox, иначе Chromium не запустится без root.
  • Docker — нужны дополнительные зависимости. Используйте base image node:18-bullseye и доустанавливайте chromium-browser через apt, затем указывайте путь к нему явно вместо встроенного.
# Dockerfile для VPS/Docker
FROM node:18-bullseye-slim

RUN apt-get update && apt-get install -y \
    chromium \
    --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# Монтируем сессию как volume
VOLUME ["/root/.whatsapp-events-scraper"]

CMD ["node", "scraper.js"]

Чеклист запуска

Шаг Действие Нюанс
Node.js Установить v18+ v16 не поддерживается
npm install Скачает Chromium автоматически ~150MB, нужен стабильный интернет
.env файл OPENAI_API_KEY, SUPABASE_URL, SUPABASE_KEY,
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
Не коммитьте в git
Первый запуск Отсканировать QR с телефона Телефон должен оставаться онлайн
Сессия Убедиться, что папка сессии персистентна Volume в Docker, не удалять при деплое
Расписание cron (Linux) или Task Scheduler (Windows) Оптимально каждые 2–4 часа
Мониторинг Алерт в Telegram при auth_failure Сессия может слететь без предупреждения
Модерация Admin-панель для одобрения ивентов Не публикуйте автоматически без ревью

Общая архитектура системы

Полный пайплайн от WhatsApp до сайта выглядит так:

WhatsApp Группы/Каналы
        |
        v
  whatsapp-web.js        <- автоматизация WhatsApp Web через Puppeteer
        |
        v
  Фильтр по чату         <- keyword matching + whitelist ID
        |
        v
  Фильтр дублей          <- string similarity против свежих записей в БД
        |
        v
  OpenAI gpt-4o-mini     <- классификация + структурирование в JSON
        |
     /     \
is_event   not_event
   |           |
   v         drop
Supabase      
(is_approved=false)
   |
   v
Telegram-бот             <- уведомление команды о новом ивенте
   |
   v
Ручная модерация         <- одобрить / отклонить в admin-панели
   |
   v
Сайт ивентов             <- Supabase REST API или real-time подписка

Фундаментальный риск архитектуры

Вся система строится на том, что WhatsApp Web остаётся доступным и whatsapp-web.js успевает за внутренними изменениями Meta. Это не официальный канал интеграции — Meta не обязана уведомлять о breaking changes и периодически намеренно усложняет реверс-инжиниринг своего протокола.

Для продакшн-системы закладывайте:

  • Мониторинг auth_failure и disconnected событий с немедленными алертами
  • Регулярные обновления whatsapp-web.js (следите за релизами на GitHub)
  • Fallback-источники данных — Telegram-каналы с теми же организаторами, Instagram, городские сайты афиш
  • Архитектуру, в которой WhatsApp — один из источников, а не единственный