Files

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

  1. React inputs — nativeSet REQUIRED: React apps (TokenHarbor, Mistral, Cursor, ElevenLabs, etc.) hijack HTMLInputElement.prototype.value. Standard el.value = 'x' fills the field visually but React state doesn't update → buttons stay disabled. Use Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, value) + dispatchEvent('input'). See references/react-input-fix.md in th-autoreg skill.

  2. Page.enable not found — connected to browser-level WS instead of page-level. Use connect_browser() for browser-level, connect_page() for page-level.

  3. --no-restore-last-session is critical — without it, Chrome restores previous session tabs (often extension pages like chrome-extension://...), and the URL argument is ignored.

  4. Profile reuse — same profile means same cookies/storage. Use unique profiles for isolation.

  5. Port conflicts — kill existing chrome on the same CDP port before launching.

  6. Python 3.14 + greenlet — nodriver and patchright crash with ModuleNotFoundError: No module named 'greenlet._greenlet'. Use CDP directly (no greenlet dependency) or Python 3.11.

  7. CDP WebSocket response ordering — rapid Runtime.evaluate calls can return responses out of order. Add time.sleep(0.5-1) between calls, or use sequential await with a single Runtime.evaluate per turn.

  8. Tab reuse for multi-account flows — always create fresh CDP tabs. Reusing logged-in tabs breaks signup flows. Use PUT /json/new?url=about:blank for each new registration.

  9. 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, clearBrowserCookies wipes 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-dir and --remote-debugging-port per thread.

  10. Python 3.11 + hermes venv PermissionError: When running py -3.11 or python3, the hermes venv's site-packages can leak into sys.path, causing PermissionError: [Errno 13] on playwright/camoufox imports. Fix: PYTHONPATH= py -3.11 script.py or use -I (isolated mode).

  11. Sync CDP client (simpler than async): For single-threaded scripts, use websocket-client + urllib.request instead of aiohttp. No event loop, no greenlet issues. See references/github-autoreg-email-domain-blocklist.md for the full pattern.

  12. Camoufox proxy format: playwright_proxy_config() from core/proxy_utils.py splits proxy URLs into {server, username, password}. Embedding credentials in the server field causes NS_ERROR_PROXY_CONNECTION_REFUSED. Always pass geoip=True in 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){}")