Задачи вида «крестик формы уехал куда-то влево» или «ленту не прокрутить мышью» не проверить снимком страницы и не проверить 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-dirheadless 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 — даже если зонд упал.
Баг вёрстки видят пользователи, а у вас не воспроизводится?
Пришлите адрес и описание — воспроизведу зондом на нужной ширине и найду правило, которое ломает раскладку.
// загружаю комментарии…