8.7 KiB
CDP Browser Automation (Windows)
Reusable Chrome DevTools Protocol pattern for browser automation on Windows.
Uses aiohttp WebSocket + subprocess Chrome launch. Avoids nodriver/playwright greenlet issues on Python 3.14.
CDP Client Class
class CDP:
def __init__(self):
self.ws = None; self.s = None; self._id = 0
async def connect_browser(self, ws_url):
"""Browser-level connection (for Target.createTarget etc)."""
self.s = aiohttp.ClientSession()
self.ws = await self.s.ws_connect(ws_url, max_msg_size=0)
async def connect_page(self, ws_url):
"""Page-level connection (for Page.*, Runtime.*)."""
self.s = aiohttp.ClientSession()
self.ws = await self.s.ws_connect(ws_url, max_msg_size=0)
await self.cmd("Page.enable"); await self.cmd("Runtime.enable")
async def cmd(self, method, params=None):
self._id += 1
await self.ws.send_json({"id":self._id,"method":method,"params":params or {}})
while True:
r = await self.ws.receive_json()
if r.get("id") == self._id:
if "error" in r: return None
return r.get("result",{})
async def js(self, expr, await_promise=False):
r = await self.cmd("Runtime.evaluate", {
"expression":expr,"returnByValue":True,"awaitPromise":await_promise
})
if not r or r.get("exceptionDetails"): return None
return r.get("result",{}).get("value")
Chrome Launch (Windows)
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
CDP_PORT = 9229
PROFILE = r"C:\path\to\browser_profile"
def launch_chrome():
# Kill existing chrome on port
subprocess.run(f'for /f "tokens=5" %a in (\'netstat -ano ^| findstr :{CDP_PORT}\') do taskkill /F /PID %a',
shell=True, capture_output=True, timeout=5)
subprocess.run('taskkill /F /IM chrome.exe 2>nul', shell=True, capture_output=True, timeout=5)
time.sleep(2)
proc = subprocess.Popen([
CHROME,
f"--remote-debugging-port={CDP_PORT}",
f"--user-data-dir={PROFILE}",
"--no-sandbox","--disable-gpu",
"--no-first-run","--no-default-browser-check",
"--no-restore-last-session", # CRITICAL: prevents extension page restore
"--disable-blink-features=AutomationControlled",
"--window-size=1280,900",
"--new-window","about:blank",
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(4)
return proc
Connect & Create Tab
# Browser-level
async with aiohttp.ClientSession() as s:
async with s.get(f"http://127.0.0.1:{CDP_PORT}/json/version") as r:
b_ws = (await r.json())["webSocketDebuggerUrl"]
b_cdp = CDP()
await b_cdp.connect_browser(b_ws)
tgt = await b_cdp.cmd("Target.createTarget", {"url":"about:blank"})
tid = tgt["targetId"]
await b_cdp.close()
# Page-level
async with aiohttp.ClientSession() as s:
async with s.get(f"http://127.0.0.1:{CDP_PORT}/json") as r:
pages = await r.json()
p_ws = next(pg["webSocketDebuggerUrl"] for pg in pages if pg.get("id") == tid)
cdp = CDP()
await cdp.connect_page(p_ws)
Common Operations
# Navigate
await cdp.cmd("Page.navigate", {"url": url})
# Wait for load
while await cdp.js("document.readyState") != "complete":
await asyncio.sleep(0.5)
# Click element by text
await cdp.js(f"""
(function(){{
var els=document.querySelectorAll('button,a,span');
for(var i=0;i<els.length;i++){{
if(els[i].textContent.includes({json.dumps(text)})){{els[i].click();return true;}}
}}
return false;
}})()
""")
# Fill input — STANDARD (works for vanilla HTML)
await cdp.js(f"""
(function(){{
var el=document.querySelector('input[name="{name}"]');
if(!el)return false;
el.focus();el.value={json.dumps(value)};
el.dispatchEvent(new Event('input',{{bubbles:true}}));
el.dispatchEvent(new Event('change',{{bubbles:true}}));
return true;
}})()
""")
# Fill input — REACT (use for React/Next.js apps)
# React hijacks HTMLInputElement.prototype.value. Standard el.value = 'x'
# does NOT trigger React state. Buttons stay disabled. Use native setter:
await cdp.js(f"""
(function(){{
var nativeSet = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
var el = document.querySelector('input[type="email"]');
if(!el) return false;
nativeSet.call(el, {json.dumps(value)});
el.dispatchEvent(new Event('input', {{bubbles: true}}));
return true;
}})()
""")
# Check all checkboxes
await cdp.js("""
(function(){var cbs=document.querySelectorAll('input[type="checkbox"]');
for(var i=0;i<cbs.length;i++){cbs[i].checked=true;
cbs[i].dispatchEvent(new Event('change',{bubbles:true}));}
return cbs.length;})()
""")
# Get URL / page text
url = await cdp.js("window.location.href")
text = await cdp.js("document.body?document.body.innerText.substring(0,3000):''")
Pitfalls
-
React inputs — nativeSet REQUIRED: React apps (TokenHarbor, Mistral, Cursor, ElevenLabs, etc.) hijack
HTMLInputElement.prototype.value. Standardel.value = 'x'fills the field visually but React state doesn't update → buttons stay disabled. UseObject.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, value)+dispatchEvent('input'). Seereferences/react-input-fix.mdinth-autoregskill. -
Page.enablenot found — connected to browser-level WS instead of page-level. Useconnect_browser()for browser-level,connect_page()for page-level. -
--no-restore-last-sessionis critical — without it, Chrome restores previous session tabs (often extension pages likechrome-extension://...), and the URL argument is ignored. -
Profile reuse — same profile means same cookies/storage. Use unique profiles for isolation.
-
Port conflicts — kill existing chrome on the same CDP port before launching.
-
Python 3.14 + greenlet —
nodriverandpatchrightcrash withModuleNotFoundError: No module named 'greenlet._greenlet'. Use CDP directly (no greenlet dependency) or Python 3.11. -
CDP WebSocket response ordering — rapid
Runtime.evaluatecalls can return responses out of order. Addtime.sleep(0.5-1)between calls, or use sequentialawaitwith a singleRuntime.evaluateper turn. -
Tab reuse for multi-account flows — always create fresh CDP tabs. Reusing logged-in tabs breaks signup flows. Use
PUT /json/new?url=about:blankfor each new registration. -
CRITICAL — Multi-thread cookie sharing: All CDP tabs in a single Chrome instance share the same cookie jar. When multiple threads each create their own tab, the first thread's login sets cookies that ALL other threads see. Subsequent signups redirect to dashboard (already logged in), the script thinks signup succeeded, and creates API keys on the first account. Result: 100+ keys on 1 account. Fix: (a)
Network.clearBrowserCookies+ clear localStorage/IndexedDB before each account, AND (b) run single-threaded. With multiple threads,clearBrowserCookieswipes cookies from ALL tabs simultaneously — thread A's login gets wiped by thread B's clear. For parallelism, launch separate Chrome instances with different--user-data-dirand--remote-debugging-portper thread. -
Python 3.11 + hermes venv PermissionError: When running
py -3.11orpython3, the hermes venv's site-packages can leak intosys.path, causingPermissionError: [Errno 13]on playwright/camoufox imports. Fix:PYTHONPATH= py -3.11 script.pyor use-I(isolated mode). -
Sync CDP client (simpler than async): For single-threaded scripts, use
websocket-client+urllib.requestinstead of aiohttp. No event loop, no greenlet issues. Seereferences/github-autoreg-email-domain-blocklist.mdfor the full pattern. -
Camoufox proxy format:
playwright_proxy_config()fromcore/proxy_utils.pysplits proxy URLs into{server, username, password}. Embedding credentials in theserverfield causesNS_ERROR_PROXY_CONNECTION_REFUSED. Always passgeoip=Truein Camoufox launch_opts when using a proxy.
def clear_session(cdp):
"""Clear all cookies and storage — prevents cross-account key leakage."""
cdp["id"] = cdp.get("id", 0) + 1
mid = cdp["id"]
cdp["ws"].send(json.dumps({"id": mid, "method": "Network.clearBrowserCookies"}))
time.sleep(0.3)
# Also clear localStorage, sessionStorage, and IndexedDB
js(cdp, "try{localStorage.clear();sessionStorage.clear();"
"indexedDB.databases().then(dbs=>dbs.forEach(db=>indexedDB.deleteDatabase(db.name)));"
"}catch(e){}")