
Привет, Хабр! С вами снова Владимир Михайлов, Marketing Analyst в Garage Eight. В прошлой статье я рассказывал, как на одном из своих прошлых проектов мы с командой собрали MVP внутренней платформы конкурентной аналитики: превратили Sensor Tower в источник данных, настроили их обработку и доставку отчетов в Slack. Этот MVP стал фундаментом для следующего этапа. Сегодня расскажу, как мы использовали AI, чтобы отслеживать новые возможности внутри продуктов конкурентов и отделять реальные изменения от технического шума.
Большинство инструментов конкурентного мониторинга следят за тем, что легко увидеть снаружи: рекламой, SEO, ценами, публикациями, обновлениями приложений. Но продуктовой команде часто интереснее, что именно конкурент добавил в продукт: может, это новый AI-инструмент, способ работы с данными или расширение существующей функции. И вот как раз такие изменения часто не анонсируются вообще — их просто тихо выкатывают пользователям.
Узнавать об изменениях других компаний важно, поэтому я решил автоматизировать именно этот процесс. В результате получилась система, которая авторизуется в продуктах конкурентов, исследует доступный интерфейс, строит семантический снимок функциональности, сравнивает его с предыдущим состоянием и отправляет в Slack только реальные продуктовые изменения.
Не:
у кнопки поменялся testid
и не:
раздел переехал на другой URL
а:
NEW FEATURE — Smart Recommendations
Пользователь теперь получает персонализированные рекомендации непосредственно внутри продукта.
Вот только построить такой мониторинг значительно сложнее, чем просто сравнивать две версии сайта.
Первая версия идеи была очевидной: снимаем DOM сегодня, завтра снимаем еще раз — и сравниваем.
old_html = load_snapshot("yesterday.html")
new_html = load_snapshot("today.html")
diff = compare(old_html, new_html)
Проблема в том, что современный SPA может изменить тысячи элементов без единого изменения продукта:
class changed — поменялось название CSS-класса;
testid changed — изменился идентификатор для тестов;
URL changed — раздел переехал на другой адрес;
component moved — компонент переместился внутри страницы;
label changed — поменялся текст на кнопке;
experiment variant changed — пользователю показали другой вариант A/B-теста;
DOM structure changed — изменилась структура разметки.
В результате получается отличный детектор фронтенд-деплоев и довольно плохой детектор продуктовых изменений — я бы каждый день получал сотни уведомлений «что-то поменялось», но почти все они были бы мусором.
Я довольно быстро понял, что задаю неправильный вопрос. Нужно выяснить, не что изменилось на странице, а что пользователь теперь может делать такого, чего не мог делать раньше, — это и стало основной идеей проекта.
В упрощенном виде система выглядит так:
Competitor
↓
Authenticated Browser
↓
AI Discovery
↓
Feature Model
↓
Semantic Snapshot
↓
Compare
↓
Business Significance Filter
↓
Slack
где:
Competitor — продукт конкурента, изменения которого мы отслеживаем;
Authenticated Browser — браузер с сохраненной авторизованной сессией, который позволяет видеть интерфейс так же, как его видит реальный пользователь;
AI Discovery — AI, который исследует интерфейс и определяет доступные пользователю функции и действия;
Feature Model — здесь найденные возможности преобразуются в структурированную модель продукта;
Semantic Snapshot — сохраняет текущее состояние продукта в виде набора его возможностей, а не HTML-страницы;
Compare — новое состояние сравнивается с предыдущим, и определяются добавленные, удаленные или измененные возможности;
Business Significance Filter — на этом этапе отбрасываются технические и несущественные изменения, остаются изменения, важные с точки зрения продукта или бизнеса;
Slack — тут значимые изменения отправляются команде в виде уведомления с кратким описанием.
Важный архитектурный принцип заключается в том, что AI не получает два HTML-файла с просьбой: «Скажи, что изменилось». LLM используется раньше — для преобразования интерфейса в структурированную модель продукта. А сравнение двух состояний после выполняется детерминированным кодом. Проще говоря, обычный Python сравнивает два структурированных объекта, за счет чего мы получаем стабильный и воспроизводимый результат.
При этом одних автоматических уведомлений оказалось недостаточно: Slack хорошо отвечает на вопрос «Что изменилось сегодня?», но для аналитической работы нужны более сложные запросы — что нового появилось у конкурентов за месяц, какие AI-функции уже есть на рынке, какие возможности есть у одного конкурента и отсутствуют у другого.
Именно поэтому я добавил отдельный аналитический слой и подключил его к Claude через MCP. Claude получил набор read-only инструментов для работы с накопленной историей:
получение текущих функций каждого конкурента;
поиск NEW/UPDATED/REMOVED за выбранный период;
фильтрацию по категориям;
просмотр конкретной функции и ее capabilities;
проверку покрытия мониторингом и доступности исторических данных.
Claude работает уже с подготовленными структурированными данными. В результате вместо SQL, JSON и ручного просмотра истории можно спросить обычным языком: какие новые функции и возможности есть или появились у конкурентов за последний месяц, но пока не обнаружены у остальных?
Дополнительно история мониторинга синхронизируется с Google Drive. Это позволяет использовать накопленные результаты как дополнительную базу знаний внутри отдельного Claude Project для конкурентной разведки.
В итоге получилось два интерфейса к одной системе:
Slack → push-мониторинг: система сама сообщает команде о новых и измененных функциях;
Claude → pull-аналитика: сотрудник сам задает вопрос о конкурентах, периодах, функциях и трендах и получает ответ на основе накопленной истории.
То есть Competitor Feature Monitor постепенно превратился из обычного site-monitoring скрипта в небольшой AI Competitive Intelligence Assistant: браузерные агенты собирают изменения, детерминированный слой хранит и сравнивает факты, а Claude становится разговорным интерфейсом к этой базе. В итоге мы получаем:
Competitor websites → Playwright/MCP → Feature extraction → Semantic comparison → History → Slack + Google Drive → Claude Analytics MCP → Analyst
Публичный сайт показывает далеко не всю функциональность. Например, снаружи мы видим то, что продукт показывает всем — на лендинге, в маркетинговых материалах, в документации:
Analytics
Automation
Reports
Integrations
А после авторизации:
Saved Views
Custom Alerts
Recommendations
Advanced Analytics
Automation Rules
Notifications
Account Tools
...
Многие из этих функций нельзя найти ни на публичном сайте, ни в сторе, ни в пресс-релизах: они доступны только тем, кто залогинился. Именно поэтому нужно было научить систему работать с той частью продукта, которую видит обычный авторизованный пользователь.
Для каждого конкурента я создал persistent Chrome profile — отдельную папку, в которой Chrome хранит куки, localStorage, сессии, историю:
.browser-profile/
competitor-a/
competitor-b/
competitor-c/
...
И отдельный Playwright MCP — способ, которым AI управляет браузером:
playwright-competitor-a
playwright-competitor-b
playwright-competitor-c
После первого полноценного входа следующий запуск во многих случаях выглядит так:
[pre-login] auth_check -> AUTH_OK
LOGIN_NOT_NEEDED
Монитор открывает существующий профиль, проверяет авторизацию и начинает discovery. Но именно здесь мы столкнулись с первым серьезным набором edge cases.
Самый простой вариант определить AUTH_OK:
if "/login" not in page.url:
return AUTH_OK
ненадежен. SPA может сначала показать login, затем восстановить session, или наоборот: на секунду показать authenticated UI, а потом отправить пользователя на login. Именно поэтому я выбрал вариант со state machine, где система проходит через несколько состояний:
class AuthState(Enum):
AUTH_CHECKING = "AUTH_CHECKING"
AUTH_OK = "AUTH_OK"
AUTH_EXPIRED = "AUTH_EXPIRED"
AUTH_2FA_REQUIRED = "AUTH_2FA_REQUIRED"
AUTH_CAPTCHA_REQUIRED = "AUTH_CAPTCHA_REQUIRED"
AUTH_UNKNOWN = "AUTH_UNKNOWN"
Для каждого продукта я использовал несколько независимых authenticated markers. Условно:
{
"authenticated_markers": [
"navigation-user-menu",
"account-switcher",
"logout-control"
],
"authenticated_threshold": 2
}
Есть три признака того, что мы авторизованы: меню пользователя, переключатель аккаунта и кнопка выхода. Но система считает нас авторизованными, только если найдены минимум два признака из трех, потому что один элемент может случайно совпасть или подгрузиться с задержкой, а вот два-три независимых уже надежнее.
Даже несколько markers не спасают от навигационных гонок. Во время одного реального login Playwright получил:
Execution context was destroyed,
most likely because of a navigation
При этом login фактически прошел успешно. Если воспринимать такое исключение как authentication failure, монитор может начать повторно отправлять форму, поэтому проверка состояния выполняется серией наблюдений:
def poll_until_stable(reader, classifier, max_wait):
samples = []
while time.time() < deadline:
try:
snapshot = reader.snapshot()
except Exception:
time.sleep(POLL_INTERVAL)
continue
state = classifier(snapshot)
samples.append(state)
if stable(samples):
return state
time.sleep(POLL_INTERVAL)
return AUTH_UNKNOWN
Transient navigation error означает:
skip sample → continue polling
а не:
LOGIN_FAILED
Мелкая деталь, но без нее auth automation оказывается очень хрупкой.
Это было одним из принципиальных решений. Credentials — это логины и пароли, и если AI управляет браузером, возникает соблазн просто передать ему логин и пароль, чтобы он сам всё ввел. Но тогда секрет окажется в истории запросов и ответов модели, а это уже риск утечки.
Claude управляет браузером через MCP, но логин и пароль я не передаю через MCP tool calls.
Credentials хранятся в системном credential store:
username, password = get_credentials("competitor-a")
И получает их отдельный standalone Playwright process:
OS Credential Store
↓
run_auto_login.py
↓
Chrome
Recovery выглядит так:
MCP auth check
↓
AUTH_EXPIRED
↓
close MCP browser
↓
standalone auto-login
↓
reopen profile
↓
AUTH_OK
↓
Discovery
AI проверяет авторизацию, видит, что сессия истекла, закрывает браузер, запускает отдельный процесс автологина, снова открывает профиль, проверяет, что всё хорошо, и продолжает исследование. При этом AI не знает ни логина, ни пароля — он просто получает сигнал, что можно работать.
На одном из сервисов первый реальный auto-login закончился так:
[pre-login] AUTH_EXPIRED
[post-login] AUTH_UNKNOWN
LOGIN_FAILED
Но в этот момент на телефон пришел СМС-код, и на самом деле произошло:
credentials accepted
↓
SMS 2FA
↓
detector doesn't know this screen
↓
AUTH_UNKNOWN
Логин и пароль подошли, просто из-за СМС-кода система не узнала этот экран. Тогда я добавил отдельное состояние:
AUTH_2FA_REQUIRED
Так система не пытается обходить 2FA — она останавливается и требует подтверждения от человека. После ввода кода сессия может сохраняться, и последующие запуски снова будут работать автоматически:
AUTH_OK
LOGIN_NOT_NEEDED
Получилась вполне практичная схема:
Автоматизация по умолчанию + human-in-the-loop только тогда, когда его действительно требует сервис.
Сначала логика была примерно такой:
if iframe.title == "reCAPTCHA":
return AUTH_CAPTCHA_REQUIRED
И довольно быстро дала false positive.
На странице может присутствовать invisible reCAPTCHA или обычный CAPTCHA badge, хотя никакого challenge пользователь сейчас не проходит.
Именно поэтому пришлось разделить:
CAPTCHA infrastructure exists
и:
CAPTCHA is actively blocking authentication
Так можно различить два разных состояния: «на сайте вообще есть CAPTCHA» и «нас прямо сейчас просят ее пройти». Это частая ловушка browser automation: элемент на странице есть, а состояния, которое он обычно означает, нет.
Самый интересный auth-кейс встретился на другом сервисе.
Auto-login успешно завершается:
[pre-login] AUTH_EXPIRED
[post-login] AUTH_OK
LOGIN_SUCCESS
Закрываем Chrome.
Открываем тот же physical profile:
AUTH_EXPIRED
Система логинится, всё хорошо, но стоит закрыть браузер и снова открыть профиль — опять нужно авторизоваться. Хотя профиль тот же, куки те же, всё должно сохраниться.
Причиной оказалась auth-critical session cookie:
expires: -1
session: true
Пока живет Chrome:
Chrome process
↓
session cookie
↓
AUTH_OK
Закрываем Chrome:
Chrome process dies
↓
session cookie dies
↓
AUTH_EXPIRED
Отсюда один из важных практических выводов проекта:
Persistent browser profile ≠ persistent authenticated session.
Для таких сервисов нужна другая архитектура.
Как раз на этом этапе нужно передать браузер из рук в руки: один процесс логинился, а другой подхватывал уже авторизованную сессию и продолжал работу.
Вместо:
login
↓
close Chrome
↓
reopen Chrome
↓
discovery
нужно:
start Chrome
↓
auto-login
↓
AUTH_OK
↓
keep Chrome alive
↓
AI/Playwright attaches
↓
Discovery
↓
Snapshot
↓
Compare
В первом случае мы логинимся, закрываем браузер, открываем заново — и сессия умирает. Во втором случае логинимся и не закрываем браузер, а просто подключаемся к нему другим процессом — сессия остается живой, потому что Chrome не закрывался.
Один из вариантов — CDP:
Python Playwright
↓
Chrome --remote-debugging-port
↓
authenticated context
↓
Playwright MCP connectOverCDP()
↓
AI discovery
Если сервис решил, что сессия не должна сохраняться, не надо с этим бороться — надо просто не закрывать браузер.
Изначально feature выглядела примерно так:
{
"id": "competitor-a:smart_recommendations",
"name": "Smart Recommendations",
"url": "...",
"description": "...",
"selectors": [],
"labels": []
}
Мы считали:
label_hash(feature)
structure_hash(feature)
Это позволяло обнаруживать, когда появлялась новая функция, когда она менялась, была временно недоступна и появлялась снова. Но представим два сценария.
Функция переехала с:
/analytics
на:
/insights/analytics
DOM полностью переделали, но для пользователя ничего не изменилось.
URL, DOM и название функции остались прежними. Но раньше пользователь мог выполнять действие только вручную, а теперь может настроить автоматическое выполнение — это ощутимое изменение.
Обычный structural diff в первом случае говорит:
MAJOR CHANGE!
А второй может вообще не заметить.
Для продуктовой разведки всё должно работать наоборот.
Это ключевой слой всей системы. Нужно было описать не только feature, но и что конкретно пользователь может с ее помощью делать:
{
"feature_key": "automated_action",
"capabilities": [
"perform_action",
"schedule_action"
]
}
capabilities — это стабильные смысловые единицы:
schedule_action
export_report
create_custom_alert
compare_multiple_items
weekly_frequency
Для них считается отдельный hash:
def capability_hash(feature: dict) -> str:
payload = {
"capabilities": sorted(
feature.get("capabilities", [])
)
}
return stable_hash(payload)
И теперь:
business_significant = (
new_capability_hash != old_capability_hash
)
Итого: если набор возможностей изменился — это бизнес-значимое изменение, остальное — технический шум.
После этого в системе фактически появились два независимых мира.
URL changed
DOM changed
testid changed
label changed
navigation changed
Они полезны для диагностики и аудита.
user can do something new
Именно они отправляются людям.
Например:
Событие | Product alert |
Функция переехала на другой URL | Нет |
Переделали DOM | Нет |
Переименовали кнопку | Нет |
Добавили автоматическое выполнение | Да |
Теперь можно работать с несколькими объектами вместо одного | Да |
Добавили новый вариант аналитики | Да |
Это оказалось главным архитектурным решением проекта.
Казалось бы, если мы сравниваем смысл, а не технические детали, шума быть не должно, но и тут есть подвох. Представим, что вчера AI назвал capability:
schedule_action
а сегодня:
automate_action
Продукт не изменился, а вот hash — да, поэтому capabilities нельзя генерировать как свободный текст. Если позволить AI придумывать названия самому, каждый день будет новый набор слов, и сравнение потеряет смысл.
Я ввел правило:
Capability — стабильный атомарный идентификатор подтвержденной пользовательской возможности.
То есть AI выбирает значения из установленного мной списка.
По мере роста системы для них нужен нормальный справочник категорий:
{
"automation": [
"schedule_action",
"pause_automation",
"resume_automation"
],
"analytics": [
"compare_multiple_items",
"view_historical_data",
"export_report"
]
}
Это уже не столько техническая, сколько data governance задача.
Иногда после изменения схемы система вдруг решает, что изменилось всё сразу. Именно поэтому я добавил capability_hash, когда baseline уже существовал. Получилось:
Старый snapshot:
{
"feature": "Smart Recommendations"
}
Новый:
{
"feature": "Smart Recommendations",
"capability_hash": "abc123"
}
Формально:
None != abc123
И первый запуск новой версии системы мог объявить все функции обновленными.
Поэтому:
if "capability_hash" not in old_feature:
business_significant = False
Первое появление semantic metadata — миграция схемы, а не изменение продукта.
Допустим, первое исследование продукта нашло:
23 features
15 confirmed
8 partial
Нельзя отправлять:
23 NEW FEATURES!
Это не новые функции — просто система увидела продукт впервые. Именно поэтому первый успешный run создает baseline:
data/competitor-a/
current.json
baseline/
first_run.json
И только следующие snapshots сравниваются с ним.
Принцип простой:
first observation = known state
next observation = possible change
Функция может временно пропасть:
из-за A/B тестов — разным пользователям показывают разные варианты, и в момент снимка мог попасться вариант без этой функции;
lazy loading — раздел подгружался с задержкой, и AI просто не успел его увидеть;
временной ошибки — что-то на стороне сервиса сломалось;
feature flag — функцию временно отключили переключателем;
rollout — функцию выкатывают постепенно, и не все пользователи ее еще видят;
изменения навигации — функция на месте, но до нее теперь надо добираться другим путем;
неполного discovery — AI просто не дошел до нужного раздела.
Именно поэтому одного missing observation недостаточно. Missing observation — это ситуация, когда функция не найдена в одном конкретном снимке. Но один пропуск ничего не доказывает.
У нас есть lifecycle:
present
↓
missing
↓
temporarily unavailable
↓
missing again
↓
...
↓
removal threshold
↓
removed
Например:
{
"removal_threshold": 3
}
Временное исчезновение и повторное появление при этом не летят в продуктовый Slack.
Это особенно важно для авторизованных продуктов.
Discovery разрешено:
читать
переходить по безопасным разделам
анализировать интерфейс
открывать подтвержденные navigation links
Но запрещено выполнять действия с side effects:
покупки
оплаты
заказы
изменение аккаунта
изменение персональных данных
удаление
публикация
verification
Если эффект действия непонятен:
SKIP
Иногда система знает только, что функция существует, но не может безопасно исследовать ее полностью.
Тогда мы сохраняем:
partial
Лучше получить неполный подтвержденный snapshot, чем полный, но выдуманный.
Первая версия Slack notifier отправляла технические CHANGED.
Например:
CHANGED — Advanced Analytics
Navigation link changed.
Для продуктовой команды это не особо полезно, поэтому Slack полностью отвязан от:
structure_hash
label_hash
technical severity
В него попадает только:
NEW
или:
business_significant is True
Сообщение выглядит примерно так:
🔔 Competitor A
✨ NEW
Smart Recommendations
Пользователь теперь получает персонализированные
рекомендации внутри продукта.
[View feature]
🆙 UPDATED
Custom Alerts
Теперь одно уведомление может содержать
несколько пользовательских условий.
[View feature]
Detected: 2026-09-10 · 1 new · 1 updated
Без:
DOM
testid
selector
evidence hash
navigation path
URL changed
internal IDs
Техническая информация остается внутри monitoring data — человек получает только бизнес-смысл.
Можно было написать обычные Playwright scrapers, но тогда для каждого продукта пришлось бы заранее знать:
какие страницы существуют
где находится каждая функция
какие selectors использовать
как устроена навигация
что считать одной feature
Любой редизайн превращался бы в новую задачу разработчика, поэтому я решил разделить ответственность. Python / deterministic layer отвечает за:
authentication
storage
hashes
comparison
lifecycle
safety
notifications
AI отвечает за:
interface understanding
discovery
semantic classification
feature descriptions
capability detection
В итоге LLM не решает, изменился ли hash, а Python не пытается понять, является ли новый элемент интерфейса новой пользовательской возможностью.
После нескольких месяцев monitoring появляется база: Competitor × Feature × Capability × Time. И Slack alerts становятся только первым уровнем ее использования.
Можно считать features launched / competitor / quarter, видеть, кто первым внедрил capability, и измерять, сколько времени проходит между появлением возможности у одного продукта и появлением аналога у других.
Для удобства можно вообще построить Product Feature Radar:
Capability | Product A | Product B | Product C | Product D |
Smart Recommendations | ✓ | ✓ | ||
Scheduled Actions | ✓ | ✓ | ||
Advanced Analytics | ✓ | ✓ | ✓ | |
Custom Alerts | ✓ | ✓ | ✓ |
И таблица будет обновляться автоматически.
Система постепенно проходит путь от website monitoring к market intelligence, поэтому бизнесу открываются новые метрики для аналитики:
Feature Velocity — сколько значимых функций выпускает продукт за период;
Category Velocity — какие продуктовые категории развиваются быстрее остальных;
Time-to-Follow — как быстро рынок копирует новую возможность;
Feature Convergence — какие возможности постепенно становятся стандартом рынка;
Differentiation Map — какие capabilities пока остаются уникальными.
Так конкурентная аналитика превратится в полноценную разведку рынка.
Как вам такое решение? Пользовались бы или считаете, что это уже нарушает границы цифровой этики?