Python CDP Browser Scripts
Create Python functions that connect to running MCP browser agents via Chrome DevTools Protocol (CDP) and perform complete browser automation workflows. Replaces Claude browser MCP calls entirely — zero LLM tokens consumed.
Usage
/python-cdp-scripts <platform> <action-description>
/python-cdp-scripts linkedin "scrape engagement stats for our comments"
/python-cdp-scripts linkedin "check if posts are deleted"
/python-cdp-scripts linkedin "read unread DM conversations"
How It Works
Unlike browser-script (JS scripts run via Claude's browser_run_code tool calls), this approach:
- Python connects directly to the running MCP browser via CDP port
- Reuses the existing logged-in session (cookies, tabs)
- Returns structured JSON to stdout
- Called from shell scripts — Claude is never involved
- Zero LLM tokens consumed for the automation
Architecture
Each platform has a single Python file (e.g., scripts/linkedin_browser.py) with:
find_cdp_port() — scans running Chrome/Chromium processes for remote-debugging-port flags
get_browser_and_page() — connects via CDP, reuses existing platform tab (critical: new pages don't inherit cookies)
- Individual command functions (e.g.,
search_posts(), discover_notifications(), scrape_stats())
- CLI interface via
if __name__ == "__main__" with subcommands
Workflow
Step 1: Identify the automation target
Look at the shell script (skill/*.sh) for steps that currently use claude -p with browser MCP calls purely for automation (no content decisions). These are candidates for Python CDP replacement.
Step 2: Write the function
Add to the existing platform script (e.g., scripts/linkedin_browser.py):
def new_function(param1, param2):
"""One-line description of what this does.
Returns JSON: {"field": "value", ...}
"""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser, page, is_cdp = get_browser_and_page(p)
try:
page.goto(url, wait_until="domcontentloaded")
page.wait_for_timeout(3000)
# Use page.evaluate() for in-page JS
result = page.evaluate("""() => {
// DOM manipulation, internal API calls, etc.
return { data: "value" };
}""")
return result
finally:
if not is_cdp:
page.close()
browser.close()
Step 3: Add CLI subcommand
# In main()
elif cmd == "new-command":
result = new_function(sys.argv[2], sys.argv[3])
print(json.dumps(result, indent=2))
Step 4: Test standalone
python3 scripts/linkedin_browser.py new-command arg1 arg2
Step 5: Update the shell script
Replace the claude -p block with direct Python call:
# Old: claude -p "Navigate to... run JS... extract..." (30-50K tokens)
# New:
RESULT=$(python3 "$REPO_DIR/scripts/linkedin_browser.py" new-command arg1 arg2)
# Process $RESULT with python3 -c or jq (0 tokens)
Design Rules
Reuse existing tabs — CDP-connected browsers share cookies only with existing tabs. Creating context.new_page() opens a blank session. Always find and reuse the platform's existing tab.
Prefer page.evaluate() for data extraction — runs JS in-page, has access to cookies for internal API calls (Voyager API, etc.). Faster than DOM navigation.
Use internal APIs when available — LinkedIn's Voyager API (/voyager/api/...) provides structured data. Extract CSRF token from cookies, fetch with proper headers. More reliable than DOM scraping.
Return structured JSON — every function returns a dict/list that gets json.dumps()-ed to stdout. Shell scripts consume with python3 -c "import json...".
Handle CDP port discovery robustly — multiple Chrome instances may be running. Scan all ports, check /json endpoint for pages, prefer ports with logged-in platform pages (URLs without /login/ or /uas/).
Generous timeouts — social sites are slow. 3s after navigation, 2s after scroll, 1s after click. Use wait_until="domcontentloaded" not "networkidle" (can hang).
Stderr for diagnostics, stdout for data — print debug info to stderr, JSON output to stdout. Shell scripts capture with cmd 2>/dev/null.
One function = one complete workflow — "extract all notifications", "scrape stats for a URL", "check if post is deleted". Not individual clicks.
Fallback gracefully — if CDP connection fails or page structure changed, return {ok: false, error: "reason"} instead of crashing.
No LLM decisions in Python — the script does mechanical work only. Content decisions stay in Claude prompts.
When NOT to Use
- Content generation — Claude needs to decide what to write
- Complex multi-step decisions — "if post is about X, do Y, otherwise Z" where X requires understanding
- One-off debugging — just use browser_snapshot manually
- Actions that need Claude's judgment mid-flow — use browser-script instead
The split: Python CDP handles MECHANICAL work (navigate, extract, scrape, post via API). Claude handles JUDGMENT work (pick posts, write comments, decide strategy).
CDP Connection Details
Finding the browser port
def find_cdp_port():
# Scan ps aux for --remote-debugging-port=NNNN
# Check each port's /json endpoint for platform pages
# Prefer ports with logged-in pages (feed/notifications, not login/uas)
Reusing existing tabs (CRITICAL)
def get_browser_and_page(playwright):
browser = playwright.chromium.connect_over_cdp(f"http://localhost:{port}")
context = browser.contexts[0] # Reuse existing context
# Find existing platform tab - DO NOT create new pages
for pg in context.pages:
if "linkedin.com" in pg.url and "/login" not in pg.url:
return browser, pg, True # is_cdp=True
New pages created via context.new_page() do NOT inherit cookies from the MCP browser session. This is the #1 gotcha.
Platform Notes
LinkedIn
- CDP port: found by scanning Chrome processes for
--remote-debugging-port
- Prefer ports with logged-in pages (feed, notifications URLs, not login/uas)
- Voyager API:
/voyager/api/voyagerIdentityDashNotificationCards for notifications
- Activity IDs: hidden in new React DOM, extract via control menu's Report link (
updateUrn param)
- Comment identification: use
button[aria-label*="View more options for <name>"] to find our comment container
- Old DOM selectors (
article.comments-comment-entity) no longer work -- LinkedIn uses obfuscated CSS classes
- API for posting:
linkedin_api.py handles comments, replies, likes via REST API
- Agent config:
~/.claude/browser-agent-configs/linkedin-agent.json
Reddit (future)
- old.reddit.com is simpler to automate
- Reddit API exists for most operations -- prefer API over browser
- Agent: reddit-agent
Twitter/X (future)
- Twitter API handles most operations
- Browser needed for: reading DMs, visual verification
- Agent: twitter-agent
Existing Functions
linkedin_browser.py
| Command |
Action |
Input |
Output |
notifications |
Extract notifications via Voyager API |
none |
[{type, commentUrn, activityId, authorName, ...}] |
search URL |
Search posts, extract activity IDs |
search URL |
{activity_ids: [...], posts: [{activity_id, author, text}]} |
comment-context URL |
Get comment thread for a post |
post URL |
{activity_id, comments: [{author, content}]} |
activity-id URL |
Extract activity ID from post |
post URL |
{activity_id, post_text, author} |
stats URL [PREFIX] |
Scrape reaction count on our comment |
post URL + optional content prefix |
{found, reactions, comment_preview} |
stats-batch JSON |
Batch stats for multiple posts |
JSON array of [{id, url, content_prefix}] |
[{id, url, found, reactions, comment_preview}] |
audit URL |
Check if post is live or deleted |
post URL |
{status, reactions, comments, views} |
audit-batch JSON |
Batch audit for multiple posts |
JSON array of [{id, url}] |
[{id, url, status, reactions, comments, views}] |
linkedin_api.py (companion REST API wrapper)
| Command |
Action |
Input |
Output |
comment ACTIVITY_ID TEXT |
Post comment |
activity ID + text |
{ok, comment_urn, our_url} |
reply ACTIVITY_ID PARENT_URN TEXT |
Reply to comment |
activity ID + parent URN + text |
{ok, reply_urn, permalink} |
post TEXT |
Create new post |
text |
{ok, post_urn} |
like ACTIVITY_ID |
Like a post |
activity ID |
{ok} |
delete POST_URN |
Delete a post |
post URN |
{ok} |
whoami |
Get authenticated user info |
none |
{ok, name, email} |
vs browser-script Approach
|
Python CDP |
browser-script (JS) |
| Token cost |
0 |
2 tool calls (~2-5K tokens) |
| When to use |
Shell script automation, no Claude in loop |
Mid-Claude-session, Claude deciding content |
| Execution |
python3 script.py cmd args |
browser_run_code via MCP |
| Session |
Connects to existing browser via CDP |
Runs inside MCP browser agent |
| Best for |
Stats, audit, notifications, search |
Edit comment, post with dynamic text |
1---2name: python-cdp-scripts3description: Use when the user says "convert this browser MCP call to python", "automate this with CDP", "stop using LLM tokens for this scrape", "make this a python script", or wants to replace browser-MCP-driven automation with a Python+CDP script that reuses the running MCP browser's session. Zero LLM tokens consumed once written. Per-platform (e.g., `scripts/linkedin_browser.py`).4---56# Python CDP Browser Scripts78Create Python functions that connect to running MCP browser agents via Chrome DevTools Protocol (CDP) and perform complete browser automation workflows. Replaces Claude browser MCP calls entirely — zero LLM tokens consumed.910## Usage11```12/python-cdp-scripts <platform> <action-description>13/python-cdp-scripts linkedin "scrape engagement stats for our comments"14/python-cdp-scripts linkedin "check if posts are deleted"15/python-cdp-scripts linkedin "read unread DM conversations"16```1718## How It Works1920Unlike browser-script (JS scripts run via Claude's browser_run_code tool calls), this approach:21- Python connects directly to the running MCP browser via CDP port22- Reuses the existing logged-in session (cookies, tabs)23- Returns structured JSON to stdout24- Called from shell scripts — Claude is never involved25- Zero LLM tokens consumed for the automation2627## Architecture2829Each platform has a single Python file (e.g., `scripts/linkedin_browser.py`) with:301. `find_cdp_port()` — scans running Chrome/Chromium processes for remote-debugging-port flags312. `get_browser_and_page()` — connects via CDP, reuses existing platform tab (critical: new pages don't inherit cookies)323. Individual command functions (e.g., `search_posts()`, `discover_notifications()`, `scrape_stats()`)334. CLI interface via `if __name__ == "__main__"` with subcommands3435## Workflow3637### Step 1: Identify the automation target38Look at the shell script (`skill/*.sh`) for steps that currently use `claude -p` with browser MCP calls purely for automation (no content decisions). These are candidates for Python CDP replacement.3940### Step 2: Write the function4142Add to the existing platform script (e.g., `scripts/linkedin_browser.py`):4344```python45def new_function(param1, param2):46 """One-line description of what this does.47 48 Returns JSON: {"field": "value", ...}49 """50 from playwright.sync_api import sync_playwright51 52 with sync_playwright() as p:53 browser, page, is_cdp = get_browser_and_page(p)54 55 try:56 page.goto(url, wait_until="domcontentloaded")57 page.wait_for_timeout(3000)58 59 # Use page.evaluate() for in-page JS60 result = page.evaluate("""() => {61 // DOM manipulation, internal API calls, etc.62 return { data: "value" };63 }""")64 65 return result66 67 finally:68 if not is_cdp:69 page.close()70 browser.close()71```7273### Step 3: Add CLI subcommand7475```python76# In main()77elif cmd == "new-command":78 result = new_function(sys.argv[2], sys.argv[3])79 print(json.dumps(result, indent=2))80```8182### Step 4: Test standalone8384```bash85python3 scripts/linkedin_browser.py new-command arg1 arg286```8788### Step 5: Update the shell script8990Replace the `claude -p` block with direct Python call:9192```bash93# Old: claude -p "Navigate to... run JS... extract..." (30-50K tokens)94# New:95RESULT=$(python3 "$REPO_DIR/scripts/linkedin_browser.py" new-command arg1 arg2)96# Process $RESULT with python3 -c or jq (0 tokens)97```9899## Design Rules1001011. **Reuse existing tabs** — CDP-connected browsers share cookies only with existing tabs. Creating `context.new_page()` opens a blank session. Always find and reuse the platform's existing tab.1021032. **Prefer `page.evaluate()` for data extraction** — runs JS in-page, has access to cookies for internal API calls (Voyager API, etc.). Faster than DOM navigation.1041053. **Use internal APIs when available** — LinkedIn's Voyager API (`/voyager/api/...`) provides structured data. Extract CSRF token from cookies, fetch with proper headers. More reliable than DOM scraping.1061074. **Return structured JSON** — every function returns a dict/list that gets `json.dumps()`-ed to stdout. Shell scripts consume with `python3 -c "import json..."`.1081095. **Handle CDP port discovery robustly** — multiple Chrome instances may be running. Scan all ports, check `/json` endpoint for pages, prefer ports with logged-in platform pages (URLs without `/login/` or `/uas/`).1101116. **Generous timeouts** — social sites are slow. 3s after navigation, 2s after scroll, 1s after click. Use `wait_until="domcontentloaded"` not `"networkidle"` (can hang).1121137. **Stderr for diagnostics, stdout for data** — print debug info to stderr, JSON output to stdout. Shell scripts capture with `cmd 2>/dev/null`.1141158. **One function = one complete workflow** — "extract all notifications", "scrape stats for a URL", "check if post is deleted". Not individual clicks.1161179. **Fallback gracefully** — if CDP connection fails or page structure changed, return `{ok: false, error: "reason"}` instead of crashing.11811910. **No LLM decisions in Python** — the script does mechanical work only. Content decisions stay in Claude prompts.120121## When NOT to Use122123- **Content generation** — Claude needs to decide what to write124- **Complex multi-step decisions** — "if post is about X, do Y, otherwise Z" where X requires understanding125- **One-off debugging** — just use browser_snapshot manually126- **Actions that need Claude's judgment mid-flow** — use browser-script instead127128The split: **Python CDP handles MECHANICAL work** (navigate, extract, scrape, post via API). **Claude handles JUDGMENT work** (pick posts, write comments, decide strategy).129130## CDP Connection Details131132### Finding the browser port133134```python135def find_cdp_port():136 # Scan ps aux for --remote-debugging-port=NNNN137 # Check each port's /json endpoint for platform pages138 # Prefer ports with logged-in pages (feed/notifications, not login/uas)139```140141### Reusing existing tabs (CRITICAL)142143```python144def get_browser_and_page(playwright):145 browser = playwright.chromium.connect_over_cdp(f"http://localhost:{port}")146 context = browser.contexts[0] # Reuse existing context147 # Find existing platform tab - DO NOT create new pages148 for pg in context.pages:149 if "linkedin.com" in pg.url and "/login" not in pg.url:150 return browser, pg, True # is_cdp=True151```152153New pages created via `context.new_page()` do NOT inherit cookies from the MCP browser session. This is the #1 gotcha.154155## Platform Notes156157### LinkedIn158- CDP port: found by scanning Chrome processes for `--remote-debugging-port`159- Prefer ports with logged-in pages (feed, notifications URLs, not login/uas)160- Voyager API: `/voyager/api/voyagerIdentityDashNotificationCards` for notifications161- Activity IDs: hidden in new React DOM, extract via control menu's Report link (`updateUrn` param)162- Comment identification: use `button[aria-label*="View more options for <name>"]` to find our comment container163- Old DOM selectors (`article.comments-comment-entity`) no longer work -- LinkedIn uses obfuscated CSS classes164- API for posting: `linkedin_api.py` handles comments, replies, likes via REST API165- Agent config: `~/.claude/browser-agent-configs/linkedin-agent.json`166167### Reddit (future)168- old.reddit.com is simpler to automate169- Reddit API exists for most operations -- prefer API over browser170- Agent: reddit-agent171172### Twitter/X (future)173- Twitter API handles most operations174- Browser needed for: reading DMs, visual verification175- Agent: twitter-agent176177## Existing Functions178179### linkedin_browser.py180181| Command | Action | Input | Output |182|---------|--------|-------|--------|183| `notifications` | Extract notifications via Voyager API | none | `[{type, commentUrn, activityId, authorName, ...}]` |184| `search URL` | Search posts, extract activity IDs | search URL | `{activity_ids: [...], posts: [{activity_id, author, text}]}` |185| `comment-context URL` | Get comment thread for a post | post URL | `{activity_id, comments: [{author, content}]}` |186| `activity-id URL` | Extract activity ID from post | post URL | `{activity_id, post_text, author}` |187| `stats URL [PREFIX]` | Scrape reaction count on our comment | post URL + optional content prefix | `{found, reactions, comment_preview}` |188| `stats-batch JSON` | Batch stats for multiple posts | JSON array of `[{id, url, content_prefix}]` | `[{id, url, found, reactions, comment_preview}]` |189| `audit URL` | Check if post is live or deleted | post URL | `{status, reactions, comments, views}` |190| `audit-batch JSON` | Batch audit for multiple posts | JSON array of `[{id, url}]` | `[{id, url, status, reactions, comments, views}]` |191192### linkedin_api.py (companion REST API wrapper)193194| Command | Action | Input | Output |195|---------|--------|-------|--------|196| `comment ACTIVITY_ID TEXT` | Post comment | activity ID + text | `{ok, comment_urn, our_url}` |197| `reply ACTIVITY_ID PARENT_URN TEXT` | Reply to comment | activity ID + parent URN + text | `{ok, reply_urn, permalink}` |198| `post TEXT` | Create new post | text | `{ok, post_urn}` |199| `like ACTIVITY_ID` | Like a post | activity ID | `{ok}` |200| `delete POST_URN` | Delete a post | post URN | `{ok}` |201| `whoami` | Get authenticated user info | none | `{ok, name, email}` |202203## vs browser-script Approach204205| | Python CDP | browser-script (JS) |206|--|-----------|-------------------|207| Token cost | 0 | 2 tool calls (~2-5K tokens) |208| When to use | Shell script automation, no Claude in loop | Mid-Claude-session, Claude deciding content |209| Execution | `python3 script.py cmd args` | `browser_run_code` via MCP |210| Session | Connects to existing browser via CDP | Runs inside MCP browser agent |211| Best for | Stats, audit, notifications, search | Edit comment, post with dynamic text |