Задачи вида «крестик формы уехал куда-то влево» или «ленту не прокрутить мышью» не проверить снимком страницы и не проверить HTML-кодом: надо открыть попап кликом, докрутить до блока, который грузится по мере прокрутки, снять размеры и стили, покрутить колесо. Обычно для этого берут Puppeteer или Playwright. Но всё это умеет сам Chrome через протокол инструментов разработчика (Chrome DevTools Protocol, CDP), а клиент к нему на Python — пара десятков строк и одна библиотека.

Это четвёртая часть цикла «Мастерская разработчика».

Скелет зонда

Нужны Chrome и pip install websocket-client. Chrome запускается без окна с открытым портом отладки, по порту берётся адрес вкладки, дальше команды идут JSON-сообщениями через WebSocket:

import json, shutil, subprocess, tempfile, time, urllib.request
import websocket

CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
UA = ("Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36")

def probe(url, js, w=390, h=844, port=9400):
    profile = tempfile.mkdtemp(prefix="cr-")          # свой профиль на каждый запуск
    chrome = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu",
        f"--user-data-dir={profile}", f"--remote-debugging-port={port}",
        f"--window-size={w},{h}", "about:blank"])
    try:
        for _ in range(50):                            # ждём, пока Chrome откроет порт
            try:
                tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json"))
                break
            except OSError:
                time.sleep(0.1)
        tab = next(t for t in tabs if t["type"] == "page")
        ws = websocket.create_connection(tab["webSocketDebuggerUrl"], suppress_origin=True)
        seq = 0

        def send(method, **params):
            nonlocal seq
            seq += 1
            ws.send(json.dumps({"id": seq, "method": method, "params": params}))
            while True:                                # события пропускаем, ждём свой ответ
                msg = json.loads(ws.recv())
                if msg.get("id") == seq:
                    return msg.get("result", {})

        send("Page.enable")
        send("Network.setUserAgentOverride", userAgent=UA)
        send("Emulation.setDeviceMetricsOverride", width=w, height=h,
             deviceScaleFactor=2, mobile=w <= 600)
        send("Emulation.setTouchEmulationEnabled", enabled=True, maxTouchPoints=5)
        send("Page.addScriptToEvaluateOnNewDocument",
             source="Object.defineProperty(navigator,'webdriver',{get:()=>false})")
        send("Page.navigate", url=url)
        time.sleep(3)
        res = send("Runtime.evaluate", expression=js, returnByValue=True, awaitPromise=True)
        return res["result"].get("value")
    finally:
        chrome.kill()
        chrome.wait()
        shutil.rmtree(profile, ignore_errors=True)

Без чего зонд соврёт

  • User-Agent и navigator.webdriver. Headless Chrome сообщает о себе: HeadlessChrome в User-Agent и webdriver = true. Если на сайте есть распознавание ботов или проверок скорости, зонд получит другую страницу — без блоков, догружаемых по прокрутке, без попапов — и «найдёт» баг, которого нет;
  • мультитач на мобильной ширине. Мобильный User-Agent при maxTouchPoints = 0 выглядит как эмулятор проверки скорости, и некоторые сайты считают его ботом. Отсюда setTouchEmulationEnabled;
  • свой профиль. Без --user-data-dir headless Chrome на машине с открытым обычным Chrome может молча не стартовать или отдать пустую страницу;
  • один порт — один Chrome. Два параллельных прогона на одном порту подключатся к чужой вкладке. Порт удобно считать от ширины окна, а прогоны вести по очереди.

Что передавать в Runtime.evaluate

Всё, что нужно, — одним выражением, которое возвращает объект. awaitPromise позволяет ждать внутри страницы, а не гадать с паузами снаружи:

(async () => {
  const wait = async (sel, ms = 20000) => {
    for (let t = 0; t < ms; t += 500) {
      const el = document.querySelector(sel);
      if (el) return el;
      await new Promise(r => setTimeout(r, 500));
    }
    return null;
  };
  document.querySelector('.reviews')?.scrollIntoView();   // блок грузится по приближению
  const btn = await wait('.reviews .openForm');
  if (!btn) return { error: 'блок не загрузился' };
  btn.click();
  const close = await wait('.popup .close');
  const r = close.getBoundingClientRect();
  return { x: r.x, y: r.y, w: r.width, position: getComputedStyle(close).position };
})()

Ожидание — опросом раз в полсекунды до появления элемента, а не одним sleep: блок, который грузится по прокрутке, приходит то через секунду, то через пять.

Что ещё умеет протокол

КомандаЗачем
Input.dispatchMouseEventнастоящий ввод: колесо над элементом, нажатие → движения → отпускание (перетаскивание). Программный click() многие слайдеры не слышат
Page.captureScreenshotснимок в base64. Область clip считается от начала страницы, а не от окна — после прокрутки прибавлять scrollY
Network.setCookieоткрыть страницу в нужном состоянии: сравнение, регион, язык
Fetch.enable + Fetch.fulfillRequestподменить ответ сервера на лету. Так мы воспроизводили баг карусели, когда на тестовой копии у всех карточек была одна и та же картинка: зонд дописывал к каждой ?v=N
CSS.getMatchedStylesForNodeвсе правила, применённые к элементу, с каскадными слоями — откуда пришло победившее

При подмене ответов через Fetch сообщения WebSocket надо читать в отдельном потоке. Иначе перехваченный запрос висит, пока скрипт спит в sleep, и страница не грузится.

Что этим нашли

  • крестик формы «где-то слева»: кнопка закрытия получала position: relative от класса анимации появления — стили анимации подключались позже стилей попапа;
  • лента иконок левее таблицы: переполненный флекс с justify-content: center уводил первую иконку на x = 257 при таблице на x = 285 (ширина 800). После замены центрирования на автоматические поля — 285 и 285;
  • «мышью не тянется»: до правки колесо и перетаскивание не меняли scrollLeft вовсе, после — колесо 0 → 76 (край) и дальше прокручивает страницу, перетаскивание на 150 пикселей — до края.

Уборка за собой

Каждый запуск со своим профилем оставляет папку на 0,2–0,35 ГБ. Без удаления за полтора месяца замеров их набралось около 7 ГБ во временной папке, и на системном диске осталось 23 ГБ. В скелете выше профиль создаётся через tempfile.mkdtemp() и удаляется в finally — даже если зонд упал.

Баг вёрстки видят пользователи, а у вас не воспроизводится?

Пришлите адрес и описание — воспроизведу зондом на нужной ширине и найду правило, которое ломает раскладку.

· полезно?
АЧ
Алексей Чикун веб-разработчик, команда TRIOHEAD

Делаю сайты и сервисы целиком: от вёрстки до сервера. Пишу о том, что проверил на своих проектах.

Комментарии

// загружаю комментарии…

Комментарии публикуются после проверки. Ссылки и HTML не принимаются.