---
name: cdp-browser-automation
description: Управление Chrome через Chrome DevTools Protocol (CDP) по WebSocket — навигация, заполнение форм, клики, извлечение данных, диагностика. Замена встроенному browser tool когда он не работает.
version: 1.0
author: Hermes
tags: [cdp, chrome, browser, automation, websocket, debugging]
---

## Когда использовать

- Встроенный browser_tool (`browser_navigate`, `browser_click`) не работает (Node.js проблемы, таймауты)
- Нужен точный контроль над браузером (CDP даёт больше, чем browser_tool)
- Нужны httpOnly cookies (через `Network.getAllCookies`)
- Нужно программно заполнить форму и отправить
- Нужно отследить сетевые запросы страницы

## Запуск Chrome с remote debugging

### Вариант A: Перезапустить основной Chrome (нужна сессия пользователя)

```bash
killall "Google Chrome" 2>/dev/null; sleep 2
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  --restore-last-session
```

### Вариант B: Второй экземпляр с temp-профилем (не трогает сессию)

```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  --no-first-run \
  --new-window \
  --user-data-dir=/tmp/chrome-cdp-profile
```

⚠️ **Важно:** Второй экземпляр имеет **чистый профиль** — пользователь должен залогиниться заново.

### Вариант C: Фоновый старт через terminal(background=true)

```python
# В execute_code:
terminal("""/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\
  --remote-debugging-port=9222 \\
  --no-first-run \\
  --new-window \\
  --user-data-dir=/tmp/chrome-cdp-profile""", background=True)
```

## Проверка что CDP работает

```bash
curl -s http://127.0.0.1:9222/json/version
```

Должен вернуть JSON с Chrome/версией и `webSocketDebuggerUrl`.

## Создание новой вкладки

Chrome v116+: `/json/new` требует **PUT** метод:

```bash
curl -s -X PUT http://127.0.0.1:9222/json/new
```

Вернёт JSON с `webSocketDebuggerUrl`.

Если ответ пустой (`[]`) — страниц нет, создай новую.

## Подключение и работа (через `websockets` Python)

```python
import json, asyncio, websockets

WS_URL = "ws://127.0.0.1:9222/devtools/page/..."  # из /json или /json/new

async def run():
    async with websockets.connect(WS_URL, max_size=10**7) as ws:
        # 1. Drain — очистить старые сообщения
        try:
            while True:
                await asyncio.wait_for(ws.recv(), timeout=0.2)
        except (asyncio.TimeoutError, asyncio.CancelledError):
            pass
        
        # 2. Навигация
        await ws.send(json.dumps({"id": 1, "method": "Page.navigate", "params": {"url": "https://example.com"}}))
        
        # 3. Ожидание загрузки
        try:
            while True:
                resp = await asyncio.wait_for(ws.recv(), timeout=8)
                d = json.loads(resp)
                if d.get("method") == "Page.loadEventFired":
                    break
        except asyncio.TimeoutError:
            pass
        
        # 4. Drain после загрузки
        try:
            while True:
                await asyncio.wait_for(ws.recv(), timeout=0.3)
        except (asyncio.TimeoutError, asyncio.CancelledError):
            pass
        
        # 5. Выполнить JS
        await ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate", "params": {"expression": "document.title"}}))
        
        # 6. Получить ответ
        for _ in range(5):
            try:
                resp = await asyncio.wait_for(ws.recv(), timeout=0.5)
                d = json.loads(resp)
                if d.get("id") == 2 and "result" in d:
                    print(d["result"]["result"]["value"])
                    break
            except asyncio.TimeoutError:
                break

asyncio.run(run())
```

## Получение ответа от CDP (get_response helper)

```python
async def get_response(ws, expected_id, timeout=3):
    """Ждёт ответ с конкретным id"""
    deadline = asyncio.get_event_loop().time() + timeout
    while asyncio.get_event_loop().time() < deadline:
        try:
            resp = await asyncio.wait_for(ws.recv(), timeout=0.5)
            d = json.loads(resp)
            if d.get("id") == expected_id and "result" in d:
                return d
        except asyncio.TimeoutError:
            pass
    return None
```

## Заполнение форм (textarea / input)

### Способ 1: JavaScript — прямой set value + events

```javascript
const el = document.querySelector('textarea[name=fieldname]');
el.value = 'new value';
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
```

### Способ 2: JavaScript — native value setter (если value привязан к реактивному фреймворку)

```javascript
const el = document.querySelector('textarea[name=fieldname]');
const nativeSetter = Object.getOwnPropertyDescriptor(
    window.HTMLTextAreaElement.prototype, 'value'
).set;
nativeSetter.call(el, 'new value');
el.dispatchEvent(new Event('input', {bubbles: true}));
```

### Способ 3: JavaScript — execCommand (старый, но надёжный)

```javascript
const el = document.querySelector('textarea[name=fieldname]');
el.focus();
el.select();
document.execCommand('insertText', false, 'new value');
```

### Способ 4: CDP — Input.insertText (имитирует печать)

```python
# Сначала сфокусироваться на элементе
await ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": 
    "document.querySelector('textarea[name=fieldname]').focus()"
}}))
await asyncio.sleep(0.2)

# Затем ввести текст через CDP
await ws.send(json.dumps({"id": 2, "method": "Input.insertText", "params": {"text": "new value"}}))
```

## Сабмит формы

### Способ 1: form.requestSubmit(btn) — правильный (триггерит HTML5 validation)

```javascript
const form = document.getElementById('frm');
const btn = form.querySelector('button[type=submit][name=btn]');
if (form.requestSubmit) {
    form.requestSubmit(btn);  // включает btn в POST-данные
}
```

### Способ 2: form.submit() — старый (НЕ включает кнопку в POST)

```javascript
form.submit();
```

### Способ 3: CDP mouse click на кнопке

```python
# Сначала проскроллить к кнопке
await ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": 
    "document.querySelector('button[type=submit]').scrollIntoView({block:'center'})"
}}))
await asyncio.sleep(0.5)

# Получить координаты
await ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate", "params": {"expression": 
    "(function(){ var btn = document.querySelector('button[type=submit]'); var r = btn.getBoundingClientRect(); return JSON.stringify({x: Math.round(r.left + r.width/2), y: Math.round(r.top + r.height/2)}); })()"
}}))

# Нажать
await ws.send(json.dumps({"id": 3, "method": "Input.dispatchMouseEvent", "params": {"type": "mousePressed", "x": X, "y": Y, "button": "left", "clickCount": 1}}))
await ws.send(json.dumps({"id": 4, "method": "Input.dispatchMouseEvent", "params": {"type": "mouseReleased", "x": X, "y": Y, "button": "left", "clickCount": 1}}))
```

### Способ 4: fetch + FormData (имитация POST без навигации)

```javascript
const form = document.getElementById('frm');
const fd = new FormData(form);
fd.set('resumetxt', 'new value');
fd.set('konk', 'new value');
fd.set('btn', 'Submit');
const resp = await fetch(window.location.href, {
    method: 'POST', body: fd, credentials: 'same-origin'
});
const text = await resp.text();
// Проверить, сохранились ли данные:
text.includes('ваш текст') ? 'SAVED' : 'FAILED';
```

## Отслеживание сетевых запросов

```python
# Включить мониторинг
await ws.send(json.dumps({"id": 1, "method": "Network.enable"}))

# После действий — собрать события
try:
    while True:
        resp = await asyncio.wait_for(ws.recv(), timeout=1)
        d = json.loads(resp)
        method = d.get("method", "")
        if method == "Network.requestWillBeSent":
            req = d["params"]["request"]
            print(f"REQ: {req['method']} {req['url'][:100]}")
        elif method == "Network.responseReceived":
            res = d["params"]["response"]
            print(f"RES: {res['status']} {res['url'][:100]}")
except asyncio.TimeoutError:
    pass
```

## Проверка сохранения данных

После сабмита формы:
1. Подождать 4-6 секунд (навигация после POST)
2. Проверить textarea на текущей странице (может быть POST-ответ)
3. Сделать `Page.navigate` (fresh GET) и проверить снова
4. Если данные не сохранились — возможные причины:
   - CSRF-токен (`u_token_key`) просрочен или одноразовый
   - Сессия не соответствует (второй экземпляр Chrome без логина)
   - Серверная валидация (обязательные поля, капча)
   - Кнопка не включена в POST (использовать requestSubmit(btn) а не form.submit())

## Типовой workflow (полный цикл)

```python
import json, asyncio, websockets

async def run():
    async with websockets.connect(WS_URL, max_size=10**7) as ws:
        # Drain
        try:
            while True:
                await asyncio.wait_for(ws.recv(), timeout=0.2)
        except asyncio.TimeoutError:
            pass
        
        # Navigate
        await ws.send(json.dumps({"id": 1, "method": "Page.navigate", "params": {"url": URL}}))
        
        # Wait for load
        try:
            while True:
                resp = await asyncio.wait_for(ws.recv(), timeout=8)
                if json.loads(resp).get("method") == "Page.loadEventFired":
                    break
        except asyncio.TimeoutError:
            pass
        await asyncio.sleep(2)
        
        # Drain
        try:
            while True:
                await asyncio.wait_for(ws.recv(), timeout=0.3)
        except asyncio.TimeoutError:
            pass
        
        # Execute JS
        await ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate", "params": {"expression": JS_CODE}}))
        
        # Collect responses
        try:
            while True:
                resp = await asyncio.wait_for(ws.recv(), timeout=2)
                d = json.loads(resp)
                if d.get("id") == 2 and "result" in d:
                    print(d["result"]["result"]["value"])
                    break
        except asyncio.TimeoutError:
            pass

asyncio.run(run())
```

## GetMyBoat особенности

### CAPTCHA на логине — хард-блокер
GetMyBoat использует **reCAPTCHA** (или аналогичный) на странице `/auth/signin/` (открывается как модал). Автоматический вход через CDP или browser_tool **невозможен** — запрос не проходит, страница остаётся на форме логина.

**Решения:**
1. **Ручной вход** — пользователь логинится сам, затем передаёт куки
2. **Inject cookies** — после ручного входа сохранить `document.cookie` через `browser_console` и передать их в новый сеанс
3. **Pre-saved session** — открыть Chrome с `--user-data-dir` где уже есть активная сессия GetMyBoat
4. **API key** — если у пользователя есть API-доступ к GetMyBoat, использовать его вместо браузера

### Сессия сбрасывается при подозрительных действиях
GetMyBoat имеет **бот-детекцию**. Триггеры сброса сессии:
- Скролл страницы вниз (может быть расценён как нечеловеческий)
- Слишком быстрые клики/ввод
- Открытие нескольких вкладок за короткое время
- Любое действие после GET-навигации (может потребоваться повторный вход)

**Совет:** после каждого успешного шага проверять что сессия жива (через `browser_console` проверить наличие `[class*="nav"]` элементов дашборда).

### Форма редактора — автосохранение
Редактор объявления GetMyBoat **не имеет кнопки "Save"**. Все изменения сохраняются автоматически при изменении полей (через `change`/`blur` события). Это значит:
- ✅ `browser_type` или JS `el.dispatchEvent(new Event('change', {bubbles: true}))` достаточно
- ✅ Не нужно искать кнопку Submit/Save
- ✅ Данные сохраняются между переходами по табам (Essentials → Vessel Details → Pricing)
- ❌ Нет возможности "отменить" изменения

### Структура редактора (табы)
```
Back to listing | Essentials | Location | Photos | Vessel Details | Pricing
```
- **Essentials**: title, short name, description (требует ручного заполнения), captain option, категории (React-автокомплит)
- **Location**: адрес, координаты
- **Photos**: drag-and-drop загрузка (требует OS file dialog — не автоматизируется через CDP)
- **Vessel Details**: Make, Model, Year, Length, Capacity, Engine specs, Safety equipment
- **Pricing**: hourly/daily rates, cancellation policy, deposit

### Заполнение Vessel Details (Make/Model)
Make и Model — это **React-автокомплиты с фильтрацией**:
1. Ввести часть названия (например, "Fountaine") в combobox
2. Подождать пока появится listbox
3. Кликнуть по нужному option

```javascript
// Заполнение make
const makeInput = document.querySelector('input[placeholder="Search vessel make"]');
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
nativeSetter.call(makeInput, 'Fountaine');
makeInput.dispatchEvent(new Event('input', {bubbles: true}));
// Подождать 1-2 сек, потом выбрать из listbox
const option = document.querySelector('[role="listbox"] [role="option"]:nth-child(2)'); // Fountaine Pajot
option?.click();

// Заполнение model (после make)
const modelInput = document.querySelector('input[placeholder="Search Model"]');
nativeSetter.call(modelInput, 'Lipari 41');
modelInput.dispatchEvent(new Event('input', {bubbles: true}));
// Подождать и выбрать
```

### Заполнение числовых полей
Year, Length, Capacity — это `<input type="number">`. Их можно заполнить через JS:
```javascript
const inputs = document.querySelectorAll('input[type="number"]');
const map = {};
inputs.forEach(i => { map[i.name] = i; });
if(map['year']) {
  map['year'].value = 2015;
  map['year'].dispatchEvent(new Event('input', {bubbles: true}));
  map['year'].dispatchEvent(new Event('change', {bubbles: true}));
}
```

## React-автокомплиты (Autocomplete/Combobox)

При работе с React-controlled autocomplete (например, категории на GetMyBoat, SamBoat) обычные `browser_type` и `browser_console` с `el.value = ...` не срабатывают — React не видит изменения.

### Полный цикл для React autocomplete

```javascript
// 1. Найти input
const input = document.querySelector('input[placeholder="Category 1 (Primary)"]');

// 2. Установить значение через native setter (React-controlled)
const nativeSetter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype, 'value'
).set;
nativeSetter.call(input, 'Sailing');

// 3. Диспатчить input-событие (React слушает именно его)
input.dispatchEvent(new Event('input', { bubbles: true }));

// 4. Подождать пока React отрендерит listbox (2-3 сек)
// 5. Найти listbox по известному ID
const listbox = document.getElementById('primary-category-menu');
const option = Array.from(listbox.querySelectorAll('[role="option"]'))
    .find(el => el.textContent.trim() === 'Sailing');

// 6. Программно кликнуть
option.click();
```

### Поиск ID listbox'ов

Если ID listbox'ов неизвестны — найти их через консоль:
```javascript
document.querySelectorAll('[role="listbox"]').forEach(el => 
    console.log(el.id, el.textContent.trim().substring(0, 50))
);
```

Типичные паттерны ID: `*-category-menu`, `*-subcategory-menu`, `*-listbox`, `downshift-*-menu`.

Если listbox пуст — значит автокомплит не активировался (неверный event или значение).

### Чтение скрытых данных страницы

Когда автокомплит не выдает список (нет API-запроса или он закеширован локально), данные можно найти в page source:

1. **`__NEXT_DATA__` скрипт** (Next.js):
```javascript
const script = Array.from(document.querySelectorAll('script'))
    .find(s => s.id === '__NEXT_DATA__');
const data = JSON.parse(script.textContent);
// Искать в data.props.pageProps...
```

2. **HTML-источник** (категории, маппинги, enum'ы):
```javascript
const html = document.documentElement.innerHTML;
const match = html.match(/"category-1":"([^"]+)"/);
```

3. **Все скрипты** (React bundle может содержать enum):
```javascript
Array.from(document.querySelectorAll('script')).forEach(s => {
    if (s.textContent?.includes('category-')) {
        console.log('Found categories in:', s.id || 'unnamed script');
    }
});
```

### Когда autocomplete делает XHR/fetch

Если автокомплит грузит данные с сервера:

1. Перехватить fetch:
```javascript
const origFetch = window.fetch;
window.fetch = function() {
    console.log('FETCH:', arguments[0]);
    return origFetch.apply(this, arguments);
};
```

2. Затем ввести текст в input (должен появиться XHR к API)
3. Посмотреть URL запроса — можно вызывать его напрямую через `fetch()` чтобы узнать все варианты

## ⚠️ Проблемы и решения

| Проблема | Решение |
|----------|---------|
| `Connection refused` на порт 9222 | Chrome не запущен с --remote-debugging-port |
| `/json/new` вернул ошибку | Использовать PUT вместо GET |
| `max_size` ошибка websockets | Увеличить `max_size=10**7` |
| Drain не срабатывает | Ловить `asyncio.TimeoutError`, не общий `Exception` |
| Значения в форме не устанавливаются | Попробовать все 4 способа заполнения |
| React-автокомплит не показывает опции | Использовать nativeInputValueSetter + развернуть скрытый `__NEXT_DATA__`/HTML-источник для поиска доступных значений |
| Нужно выбрать опцию из всплывающего autocomplete-списка | Найти listbox по ID (обычно `*-menu` или `*-listbox`), затем программно кликнуть option |
| `form.submit()` не сохраняет данные | Попробовать `form.requestSubmit(btn)` |
| Данные в POST-ответе но не на GET | CSRF-токен, сессия, серверная валидация |
| Page.navigate не обновляет страницу | Использовать `Page.reload` или URL с cache buster |
