Прокси в Playwright на Python: настройка browser и context, авторизация и проверка
Как задать HTTP-прокси в Playwright на Python на уровне browser и context, передать логин отдельно от origin, пройти CONNECT и проверить маршрут.
Текст для того, кто гоняет Playwright на Python через прокси и хочет увидеть маршрут в логе, а не только статус 200. Примеры с фиктивными учётными данными на loopback. Top Proxy не продаёт IP и не раздаёт бесплатные списки. Ниже сервисы из каталога, которые публикуют HTTP-доступ. Это не прогон чужого продакшена.
01Где задавать прокси: browser или отдельный context
Официальный раздел Network для Python на 14 октября 2026 говорит прямо. Страницы можно грузить через HTTP-прокси или SOCKSv5. Прокси задают глобально на весь browser либо отдельно на каждый context. Это сетевой прокси браузера. Это не Playwright MCP и не мок маршрутов page.route.
Лаборатория на этой машине шла на Python 3.14.6, Playwright 1.64.0 и Chromium 156.0.8078.4. Firefox в том же прогоне был 157.0. WebKit для номера сборки не запускали отдельно, только попытку SOCKS5 с логином. TLS не выключали. ignore_https_errors нигде не ставили в True.
- Если один и тот же выход нужен всем вкладкам процесса, передайте
proxyвchromium.launch. - Если два сценария должны идти разными выходами, откройте два
browser.new_contextс разнымиproxy. - Context, у которого свой
proxy, перекрывает значение, заданное на launch. В прогоне context ушёл на прокси B, а дефолтный прокси браузера этот запрос не видел. - Прямой заход без прокси дал HTTP 200 и ноль событий на инструментированном прокси. Пока нет строки в логе прокси, статус 200 говорит только то, что кто-то ответил.
Документация browser_type.launch принимает server вида http://хост:порт или socks5://хост:порт. Короткая форма без схемы считается HTTP-прокси. То же поле есть у browser.new_context. Context по документации не делит cookies и кэш с соседним context. Это изоляция хранилища браузера, не допуск к аккаунту и не «чистота» IP.
Страница Мой IP в обычном окне показывает выход этого окна. Она не показывает маршрут Python и не показывает маршрут headless Chromium из скрипта.
02Host, порт и отдельная авторизация прокси
Поля username и password в объекте proxy в документации launch описаны как логин для HTTP-прокси, которому нужна аутентификация. Это не http_credentials. http_credentials на context это логин к origin, раздел HTTP Authentication на той же странице Network.
- Соберите
serverкакhttp://127.0.0.1:портбез логина внутри URL, если пароль передаёте полями. - Логин и пароль кладите в
usernameиpassword. Спецсимволы в лаборатории были в паролеp@ss:/#%. Playwright принял их как отдельные поля, не как кусок URL. - Не копируйте эту пару в
http_credentials, пока не проверите, что делает ваш движок после 407. - Сверьте лог прокси. Origin в лаборатории не получил заголовок
Proxy-Authorization. Утечки на origin не было.
Chromium 156 после 407 на прокси сделал повтор и подставил пару из http_credentials в прокси-авторизацию. Первый хоп в логе был authReason=missing, второй уже authorized. Поля API по-прежнему разные. Полагаться на это как на задокументированный способ логина к прокси нельзя. Для предсказуемого прокси-логина используйте proxy.username и proxy.password.
Без логина HTTP-цель через прокси с Basic вернула страницу статуса 407 и тело Proxy Authentication Required, без исключения Python. Неверный пароль в этом же режиме тоже дал 407. Верная пара дала 200, и прокси записал authorized.
Для HTTPS-цели картина другая. CONNECT без логина и CONNECT с неверным паролем не превратились в страницу 407. page.goto("https://example.com/") упёрся в TimeoutError за 8000 мс, хотя прокси уже ответил 407 на туннель. Верный логин на CONNECT прошёл: статус 200 и цель example.com:443 в логе.
Поле bypass в документации это список доменов через запятую. В прогоне bypass="127.0.0.1" дал 200 на origin и ноль событий прокси. Тот же origin при bypass="example.com" снова пошёл через прокси. Это список исключений, не «белый список разрешённых сайтов».
03Исполняемый пример на Python и проверка маршрута
Ниже короткий самостоятельный скрипт. HTTP-origin и прокси слушают только 127.0.0.1. Затем тот же Chromium делает HTTPS CONNECT на example.com. Для этого шага нужна внешняя сеть. TLS проверки не отключает. Фиктивные данные. Это не полный harness и не прогон кабинета провайдера.
Рядом лежат tests/public_lab.py, tests/lab.py, tests/check_playwright.py и пин playwright==1.64.0. Полный прогон ставит Chromium, Firefox и WebKit, плюс openssl и cwebp. Относительный путь на диске не является публичной загрузкой.
#!/usr/bin/env python3
"""Читаемый пример: Playwright Python, HTTP-прокси и CONNECT.
Фиктивные учётные данные. HTTP-origin и прокси слушают только 127.0.0.1.
HTTPS CONNECT к example.com идёт во внешнюю сеть и требует доступ в интернет.
TLS остаётся включённым. Скрипт не ставит ignore_https_errors в True.
Это короткий самостоятельный пример, не полный harness из check_playwright.py.
python3 -m pip install playwright==1.64.0
python3 -m playwright install chromium
python3 public_lab.py
"""
from __future__ import annotations
import base64
import json
import os
import re
import select
import socket
import ssl
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
USER = "lab-user"
PASSWORD = "p@ss:" + "/#%"
REDACT = "***"
TIMEOUT_MS = 8000
STRIP = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
]
FAULT = os.environ.get("PLAYWRIGHT_RU_PUBLIC_FAULT", "")
_AUTH_JSON = re.compile(r'(?i)((?:proxy-)?authorization"\s*:\s*")([^"]*)')
_AUTH_HDR = re.compile(r'(?i)((?:proxy-)?authorization\s*[:=]\s*)([^\r\n,"\']+)')
_USERINFO = re.compile(r'(?i)((?:https?|socks5h?|socks4a?)://)([^/\s"\'@]+):([^/\s"\'@]+)@')
_BASIC = re.compile(r'(?i)(\bBasic\s+)([A-Za-z0-9+/=_-]{8,})')
def redact(s: object) -> str:
text = str(s)
text = _AUTH_JSON.sub(r"\1" + REDACT, text)
text = _AUTH_HDR.sub(r"\1" + REDACT, text)
text = _USERINFO.sub(r"\1***:***@", text)
text = _BASIC.sub(r"\1" + REDACT, text)
blob = base64.b64encode(f"{USER}:{PASSWORD}".encode("utf-8")).decode("ascii")
return text.replace(PASSWORD, REDACT).replace(blob, REDACT)
def isolate_env() -> None:
for key in STRIP:
os.environ.pop(key, None)
def close_sock(sock: socket.socket | None) -> None:
if sock is None:
return
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
sock.close()
except OSError:
pass
def leftover_listen(ports: list[int]) -> list[int]:
still = []
for port in ports:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.2)
try:
s.connect(("127.0.0.1", int(port)))
still.append(int(port))
except OSError:
pass
finally:
s.close()
return still
class Origin(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None: # noqa: A003
return
def do_GET(self) -> None: # noqa: N802
leaked = "proxy-authorization" in {k.lower() for k in self.headers.keys()}
body = json.dumps({"ok": True, "path": self.path, "leakedProxyAuth": leaked}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
class Tracked(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, addr, handler):
super().__init__(addr, handler)
self.accepted: list[socket.socket] = []
def get_request(self):
req, addr = super().get_request()
self.accepted.append(req)
return req, addr
def start_origin() -> tuple[Tracked, threading.Event, threading.Thread]:
if FAULT == "start":
raise RuntimeError("inject-public-start")
server = Tracked(("127.0.0.1", 0), Origin)
stop = threading.Event()
def loop() -> None:
server.socket.settimeout(0.2)
while not stop.is_set():
try:
server.handle_request()
except OSError:
if stop.is_set():
break
t = threading.Thread(target=loop, name="public-origin", daemon=True)
t.start()
return server, stop, t
def start_proxy(require_auth: bool) -> dict:
if FAULT == "proxy-start":
raise RuntimeError("inject-public-proxy-start")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.listen(16)
sock.settimeout(0.3)
stop = threading.Event()
events: list[dict] = []
expected = "Basic " + base64.b64encode(f"{USER}:{PASSWORD}".encode("utf-8")).decode("ascii")
def handle(conn: socket.socket) -> None:
try:
data = b""
conn.settimeout(8)
while b"\r\n\r\n" not in data:
chunk = conn.recv(4096)
if not chunk:
return
data += chunk
head = data.split(b"\r\n\r\n", 1)[0].decode("iso-8859-1", errors="replace")
lines = head.split("\r\n")
first = lines[0].split(" ")
method = first[0] if first else ""
target = first[1] if len(first) > 1 else ""
headers = {}
for line in lines[1:]:
if ":" in line:
k, v = line.split(":", 1)
headers[k.strip().lower()] = v.strip()
rec = {"method": method, "target": target, "authorized": False}
if require_auth:
got = headers.get("proxy-authorization")
if got != expected:
rec["authReason"] = "missing" if not got else "wrong"
events.append(rec)
body = b"Proxy Authentication Required"
conn.sendall(
b"HTTP/1.1 407 Proxy Authentication Required\r\n"
b"Proxy-Authenticate: Basic realm=\"lab\"\r\n"
b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body
)
return
rec["authorized"] = True
events.append(rec)
if method.upper() == "CONNECT":
host, port_s = target.rsplit(":", 1)
dest = socket.create_connection((host, int(port_s)), timeout=5)
conn.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
halt = threading.Event()
sockets = [conn, dest]
while not halt.is_set() and not stop.is_set():
readable, _, _ = select.select(sockets, [], [], 0.2)
for src in readable:
chunk = src.recv(65536)
if not chunk:
halt.set()
break
dst = dest if src is conn else conn
dst.sendall(chunk)
close_sock(dest)
return
parsed = urlsplit(target)
dest = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=5)
path = parsed.path or "/"
fwd = f"{method} {path} HTTP/1.1\r\nHost: {parsed.hostname}:{parsed.port or 80}\r\nConnection: close\r\n\r\n"
dest.sendall(fwd.encode("ascii"))
while True:
chunk = dest.recv(65536)
if not chunk:
break
conn.sendall(chunk)
close_sock(dest)
except OSError:
return
finally:
close_sock(conn)
def accept() -> None:
while not stop.is_set():
try:
conn, _ = sock.accept()
except OSError:
continue
threading.Thread(target=handle, args=(conn,), daemon=True, name="public-proxy-client").start()
t = threading.Thread(target=accept, name="public-proxy", daemon=True)
t.start()
return {
"sock": sock,
"stop": stop,
"thread": t,
"port": int(sock.getsockname()[1]),
"events": events,
}
def shutdown(origin, origin_stop, proxy) -> None:
if FAULT == "shutdown":
pass
origin_stop.set()
if proxy:
proxy["stop"].set()
close_sock(proxy["sock"])
if origin:
for acc in getattr(origin, "accepted", []):
close_sock(acc)
try:
origin.server_close()
except OSError:
pass
close_sock(getattr(origin, "socket", None))
def main() -> int:
isolate_env()
origin = None
origin_stop = threading.Event()
proxy = None
browser = None
pw = None
ports: list[int] = []
out: dict = {"cleaned": False, "leftoverListen": False, "operationError": None, "cases": []}
try:
if FAULT == "constructor":
ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain("/no/such/cert.pem")
origin, origin_stop, _ot = start_origin()
origin_port = int(origin.server_address[1])
ports.append(origin_port)
proxy = start_proxy(require_auth=True)
ports.append(proxy["port"])
from playwright.sync_api import sync_playwright
pw = sync_playwright().start()
missing = pw.chromium.launch(
timeout=30000,
proxy={"server": f"http://127.0.0.1:{proxy['port']}"},
)
page = missing.new_page()
page.set_default_timeout(TIMEOUT_MS)
missing_status = None
missing_err = None
try:
res = page.goto(f"http://127.0.0.1:{origin_port}/", wait_until="domcontentloaded")
missing_status = res.status if res else None
except Exception as e: # noqa: BLE001
missing_err = redact(f"{type(e).__name__}: {e}")
missing.close()
out["cases"].append({"name": "http-auth-missing", "status": missing_status, "error": missing_err})
browser = pw.chromium.launch(
timeout=30000,
proxy={
"server": f"http://127.0.0.1:{proxy['port']}",
"username": USER,
"password": PASSWORD,
},
)
page = browser.new_page()
page.set_default_timeout(TIMEOUT_MS)
res = page.goto(f"http://127.0.0.1:{origin_port}/", wait_until="domcontentloaded")
body = page.text_content("body")
leaked = False
try:
leaked = bool(json.loads(body or "{}").get("leakedProxyAuth"))
except json.JSONDecodeError:
pass
out["cases"].append(
{
"name": "http-auth-ok",
"status": res.status if res else None,
"body": body,
"leakedProxyAuth": leaked,
}
)
https_err = None
https_status = None
try:
res_https = page.goto("https://example.com/", wait_until="domcontentloaded", timeout=TIMEOUT_MS)
https_status = res_https.status if res_https else None
except Exception as e: # noqa: BLE001
https_err = redact(f"{type(e).__name__}: {e}")
connect_hits = [e for e in proxy["events"] if e.get("method") == "CONNECT"]
out["cases"].append(
{
"name": "https-connect-example",
"status": https_status,
"error": https_err,
"connectTargets": [e.get("target") for e in connect_hits],
}
)
browser.close()
browser = None
pw.stop()
pw = None
except Exception as e: # noqa: BLE001
out["operationError"] = redact(f"{type(e).__name__}: {e}")
finally:
try:
if browser is not None:
browser.close()
except Exception as e: # noqa: BLE001
out["operationError"] = out.get("operationError") or redact(str(e))
try:
if pw is not None:
pw.stop()
except Exception:
pass
shutdown(origin, origin_stop, proxy)
time.sleep(0.3)
left = leftover_listen(ports)
out["leftoverListen"] = bool(left)
out["cleaned"] = not left
out["ports"] = ports
print(json.dumps(out, ensure_ascii=False, indent=2))
ok = bool(out["cleaned"]) and (bool(FAULT) or out.get("operationError") is None)
return 0 if ok else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as e: # noqa: BLE001
print(json.dumps({"cleaned": False, "operationError": redact(e)}, ensure_ascii=False))
raise SystemExit(1)
- Для короткого примера поставьте Playwright 1.64.0 и Chromium. Для полного harness дополнительно поставьте Firefox и WebKit, плюс openssl и cwebp в PATH.
- Снимите
HTTP_PROXYи соседние переменные, если выбираете прокси в коде. - Задайте
proxy.serverна host и порт. Логин добавьте полями, не в URL origin. - Сделайте
page.gotoна свой endpoint. Для HTTP смотрите absolute-form в логе прокси. Для HTTPS смотритеCONNECT хост:443. - Сверьте тело origin. В читаемом прогоне
leakedProxyAuthбыл false. - Не путайте этот вывод со страницей проверки прокси. Чекер не заменяет трассу Playwright.

Снимок таблицы утверждений того же прогона: Playwright 1.64.0, Chromium 156.0.8078.4, Python 3.14.6. Это таблица результатов кода, не обложка статьи.
04HTTPS через HTTP-прокси: CONNECT и доверие сертификату
Ключ https в URL цели не требует, чтобы сам прокси слушал TLS. Клиент шлёт CONNECT хост:порт, прокси отвечает 200 Connection Established, дальше внутри туннеля идёт TLS к origin. В лаборатории CONNECT на example.com:443 через открытый HTTP-прокси дал страницу 200. Схема прокси оставалась http://127.0.0.1:порт.
Локальный HTTPS-origin был подписан короткоживущим CA с SAN на localhost и 127.0.0.1. Chromium без этой CA в своём доверии упал с net::ERR_CERT_AUTHORITY_INVALID. CONNECT к этому порту при этом в логе прокси был. Значит туннель собрали, а проверка сертификата отказала уже после CONNECT.
Python ssl с load_verify_locations на тот же CA.pem дал HTTP 200 и полное тело. Без CA тот же клиент получил SSLCertVerificationError. Это доверие стека Python, не доверие Chromium. Подсовывать verify из Requests в браузер нельзя.
Один ограниченный заход: Firefox persistent context с каталогом профиля внутри статьи и файлом lab-ca.pem в этом каталоге. У Playwright Python нет поля, которое импортирует CA в профиль. Дистрибутив браузера и системное хранилище не меняли. ignore_https_errors не включали. Локальный HTTPS в этом профиле не стал 200. Это не браузерный успех с лабораторным CA.
Не включайте ignore_https_errors. Это выключение проверки, а не установка CA.
Доверие Python без системного хранилища: evidence/ca.pem и ssl.create_default_context().load_verify_locations. Это другой стек. Успешный HTTPS в Chromium в этом прогоне это публичный сертификат example.com после CONNECT, не лабораторный CA. Эти две ветки не складываются в «браузер принял наш CA».
05Два context: маршруты, cookies и границы изоляции
Два context одного Chromium ходили на один origin разными прокси. У каждого свой Set-Cookie. Значения b8a34d162201 и 02a02220fa3c не совпали. Банки context.cookies() тоже разошлись. Документация и обещает, что context не делит cookies и кэш.
Это граница хранилища и маршрута. Это не проверка, «пройдёт ли логин» на чужом сайте, и не обещание, что цель не склеит отпечаток. Протокол прокси не равен типу IP. Датацентр, резидент и мобильный выход это другой разговор, его смотрите в каталоге мобильных и резидентных карточек.
Context с собственным proxy перекрыл launch. Запрос на origin B остался в логе прокси B. В логе дефолтного прокси браузера цели с портом origin B не было. Одновременно открытые страницы не смешали банки cookies. Если нужен общий jar, не плодите лишний context без причины. Изоляция context не заменяет отдельные процессы, когда нужен полный сброс профиля.
06SOCKS5: DNS и ограничения конкретного движка
Документация называет SOCKSv5 наравне с HTTP в поле server. Логин в тех же полях username и password описан для HTTP-прокси. На SOCKS5 с авторизацией это само не переносится.
Chromium 156 через socks5://127.0.0.1:порт без логина открыл origin с 200. Хендшейк на нашей стороне записал METHOD 0, CMD 1, ATYP 3, назначение 127.0.0.1:порт. ATYP 3 это доменное имя, даже когда строка выглядит как адрес. Это не socks5h из curl и не обещание, что DNS всегда на стороне прокси. Другой движок мы на этот хендшейк не обобщаем.
Запуск с socks5:// плюс username и password на Chromium, Firefox и WebKit оборвался одной фразой Playwright: Browser does not support socks5 proxy authentication. Навигации не было. Если провайдер отдаёт только SOCKS5 с логином, этот клиент 1.64.0 на трёх движках в нашем прогоне такой выход не взял.
Официальный текст launch отдельно уточняет, что username и password необязательны, если HTTP-прокси требует аутентификацию. Про SOCKS-логин там такой фразы нет. Наш прогон это совпал: HTTP Basic живёт, SOCKS5 с логином отвергается на старте браузера. Не переносите семантику curl socks5h:// на Playwright. Пока нет хендшейка на Firefox и WebKit без логина, их DNS и ATYP считайте непроверенными.
07Ошибки 407, отказ соединения и ограничение времени
Смотрите исключение и лог прокси вместе. Одно число timeout не описывает слой.
В коде лаборатории лимит навигации задан явно: page.goto(..., timeout=8000). Это timeout операции goto, не скрытый per-read у сокета прокси. Если прокси принял CONNECT и молчит, вы увидите тот же TimeoutError, что и на задержанном HTTP-теле. Если порт занят сокетом без listen, Chromium 156 в этом прогоне не ждал «вечно»: он вернул ERR_PROXY_CONNECTION_FAILED. Путать эти две ветки в мониторинге вредно. По ним чинят разные вещи: учётку, DNS, файрвол, живой процесс прокси.
Собственный сокет, который мы только bind и не listen, дал net::ERR_PROXY_CONNECTION_FAILED за 7866 мс. Это отказ на TCP к прокси, не TimeoutError. Отдельный origin, который принял соединение и молчал дольше лимита, дал TimeoutError: Page.goto: Timeout 8000ms exceeded, 8037 мс. Молчаливый TLS после рукопожатия в Python дал TimeoutError: The read operation timed out, 8016 мс. Это разные классы. Не называйте отказ таймаутом.
| Настройка или случай | Ожидаемый маршрут | Наблюдение 14.10.2026 | Ограничение |
|---|---|---|---|
| Прямой goto без proxy | Мимо прокси | HTTP 200, 0 событий прокси | Loopback, не провайдер |
launch(proxy=http://…) | GET absolute-form на прокси | 200, в логе GET на origin | Chromium 156 |
| Context proxy B при launch A | Только прокси B | 200, прокси A без этой цели | Один процесс, два context |
| HTTPS origin с лабораторным CA | CONNECT, затем отказ TLS | ERR_CERT_AUTHORITY_INVALID, CONNECT в логе | CA не в доверии Chromium |
HTTPS example.com через HTTP-прокси | CONNECT host:443 | 200, цель example.com:443 | Публичный сертификат |
| HTTP без логина на Basic-прокси | 407 на прокси | Статус 407, тело Proxy Authentication Required | Не exception |
| CONNECT без логина или с неверным паролем | 407 на туннеле | Прокси видит missing/wrong, goto даёт TimeoutError 8000 мс | Chromium не показал страницу 407 |
| Верный логин HTTP и CONNECT | 200, authorized | Origin без Proxy-Authorization | Фиктивная пара |
bypass=127.0.0.1 | Мимо прокси | 200, 0 событий | Только перечисленные хосты |
| SOCKS5 без логина | SOCKS CONNECT | 200, ATYP 3, DST 127.0.0.1 | Только Chromium в этом хендшейке |
| SOCKS5 с логином | Отказ запуска | Browser does not support socks5 proxy authentication | Chromium, Firefox, WebKit |
| Bind без listen | Отказ к прокси | ERR_PROXY_CONNECTION_FAILED, 7866 мс | Не TimeoutError |
| Задержанный ответ, timeout 8000 | Обрыв навигации | TimeoutError, 8037 мс | Это timeout API goto |
Протокол не равен происхождению IP. GET 200 не доказывает страну, допуск к аккаунту и обход любой цели. Контрольные фикстуры не являются тестом Oxylabs или Decodo.
Официальные тексты продуктов описывают HTTP-прокси с логином и паролем. Quick Start Oxylabs от 14 октября 2026 даёт curl -x ENDPOINT:PORT -U "PREFIX-USERNAME:PASSWORD" и явно меняет префикс клиента на http://, https:// или socks5h://. Резидентный endpoint pr.oxylabs.io:7777, префикс логина customer-. Запросы user:pass Decodo пишут пару на gate.decodo.com:7000, логин всегда с префиксом user-. Методы авторизации Decodo отдельно фиксируют 407 при неверной паре. Карточки Oxylabs и Decodo в каталоге открывались. HTTP 200 каталога не доказывает протокол. Продакшен-гейт с Playwright мы не вызывали. Клиентский proxy.username в Playwright совпадает с документированным HTTP user:pass. Это не прогон кабинета. Цифры cookies и миллисекунд в тексте совпадают с сохранённым файлом results.json этого каталога, без нового сетевого запуска лаборатории.





