DEV Community

Haoyang Pang
Haoyang Pang

Posted on

The Browser Automation Cheat Code Nobody Talks About: AppleScript + Chrome

Part 1: The Problem — Why Every Browser Automation Tool is Broken

The Arms Race

Every major website (Reddit, Twitter, LinkedIn, Facebook, Amazon) has invested millions in detecting automated browsers. They check for:

  • navigator.webdriver flag (Playwright/Selenium set this to true)
  • Chrome DevTools Protocol fingerprint
  • Missing browser plugins (real Chrome has extensions, automated Chrome doesn't)
  • Canvas/WebGL fingerprinting differences
  • Mouse movement patterns (or lack thereof)
  • Request timing patterns (bots are too fast, too regular)
  • TLS fingerprinting (headless browsers have different TLS signatures)
  • CDP (Chrome DevTools Protocol) artifacts

Tool-by-Tool Breakdown: Why They All Fail

Selenium

  • Sets navigator.webdriver = true — instant detection
  • Different browser fingerprint from real Chrome
  • Launches a fresh profile every time (no cookies, no history = suspicious)
  • Detectable through JavaScript: window.chrome.runtime is undefined
  • Many sites block it outright (Cloudflare, Akamai, PerimeterX)

Playwright

  • Same navigator.webdriver problem
  • Uses a patched Chromium, not real Chrome — fingerprint differs
  • Even with stealth plugins (playwright-extra, puppeteer-extra-plugin-stealth), sophisticated sites detect it
  • Reddit specifically blocks Playwright — returns 403 "Blocked" on page load
  • Can't use the user's existing cookies/session without complex injection

Puppeteer

  • Shares Playwright's problems (both use CDP)
  • Headless mode has distinct fingerprint vs headed mode
  • HeadlessChrome in User-Agent is a dead giveaway
  • Even --headless=new (new headless mode) gets caught by advanced detection

Chrome DevTools Protocol (CDP) Direct

  • Requires Chrome launched with --remote-debugging-port
  • Chrome REFUSES to enable debugging on the default profile: "DevTools remote debugging requires a non-default data directory"
  • Non-default data directory = fresh profile = no cookies = no login
  • Copying the profile is complex (multi-GB, encryption issues)

curl / HTTP Client Libraries

  • No browser fingerprint at all — easily identified as a script
  • No JavaScript execution capability
  • Reddit rate-limits aggressively (IP block after a few rapid requests)
  • Can't handle JavaScript-rendered content
  • Session cookies (HttpOnly) can't be set from outside the browser

Undetected-Chromedriver / Stealth Plugins

  • Cat-and-mouse game — patches lag behind detection updates
  • Still launches a separate browser instance
  • Doesn't have the user's real cookies, extensions, history
  • Only delays detection, doesn't prevent it
  • Breaks with every Chrome update

The Core Problem

All these tools share the same fundamental flaw: they create a synthetic browser environment. No matter how good the emulation, it's never identical to a real user's browser. Detection systems don't need to find one smoking gun — they correlate dozens of subtle signals.

Part 2: The Discovery — AppleScript is the Cheat Code

What is AppleScript Chrome Control?

macOS has a built-in automation framework called AppleScript. Chrome (and other apps) expose an AppleScript interface that allows external scripts to:

  1. Navigate tabs to URLs
  2. Execute JavaScript in any tab
  3. Read tab properties (title, URL)
  4. Control windows (open, close, resize)

The critical insight: this JavaScript executes inside the user's actual Chrome process. Not a copy. Not an emulation. The real thing.

Why This is Fundamentally Different

Traditional Automation:
  Script → Launches new browser → Controls it → Website detects fake browser

AppleScript Automation:
  Script → Talks to EXISTING Chrome → Chrome does the work → Website sees real user
Enter fullscreen mode Exit fullscreen mode

The website cannot distinguish AppleScript-triggered JavaScript from user-triggered JavaScript because there is no difference at the browser level. The JavaScript runs in the exact same V8 context, with the exact same cookies, the same extensions, the same fingerprint. Chrome doesn't know or care that the command came from AppleScript instead of a keyboard.

What This Means

Property The website sees...
Browser fingerprint The user's real Chrome (with all extensions, plugins)
Cookies The user's real cookies (including HttpOnly ones!)
TLS fingerprint Chrome's real TLS stack
navigator.webdriver false (not automation)
Login session Already authenticated
IP address The user's real IP (same as their normal browsing)
Canvas/WebGL Real GPU rendering

There is literally nothing to detect. The request IS from a real browser. Because it IS a real browser.

Part 3: The Full Technical Guide

3.1 One-Time Setup

Enable JavaScript from Apple Events

Chrome menu → View → Developer → Allow JavaScript from Apple Events

Then restart Chrome (Cmd+Q, reopen).

This is a security feature — Chrome requires explicit opt-in before allowing external JavaScript execution. It persists across restarts.

Verify It Works

osascript -e 'tell application "Google Chrome" to tell active tab of first window to execute javascript "document.title"'
Enter fullscreen mode Exit fullscreen mode

Should return the current tab's title. If you get an error about "JavaScript through AppleScript is turned off", restart Chrome.

3.2 Three Core Operations

Everything you can do reduces to three operations:

Operation 1: Navigate

osascript -e '
tell application "Google Chrome"
    tell active tab of first window
        set URL to "https://www.reddit.com"
    end tell
end tell'
Enter fullscreen mode Exit fullscreen mode

Operation 2: Execute JavaScript

osascript -e '
tell application "Google Chrome"
    tell active tab of first window
        execute javascript "1 + 1"
    end tell
end tell'
# Returns: 2
Enter fullscreen mode Exit fullscreen mode

Operation 3: Read Tab Properties

osascript -e '
tell application "Google Chrome"
    return title of active tab of first window
end tell'
Enter fullscreen mode Exit fullscreen mode

3.3 The document.title Trick — Getting Complex Data Out

AppleScript's execute javascript returns the result of the expression. But for async operations (like fetch()), we need a workaround:

  1. Run async JS that writes the result to document.title
  2. Wait for it to complete
  3. Read document.title
# Step 1: Execute async JS, store result in title
osascript -e '
tell application "Google Chrome"
    tell active tab of first window
        execute javascript "fetch(\"/api/me.json\", {credentials: \"include\"}).then(r => r.json()).then(d => { document.title = \"RESULT:\" + JSON.stringify(d) })"
    end tell
end tell'

# Step 2: Wait
sleep 2

# Step 3: Read result
osascript -e '
tell application "Google Chrome"
    return title of active tab of first window
end tell'
# Returns: RESULT:{"data":{"name":"myuser","karma":123,...}}
Enter fullscreen mode Exit fullscreen mode

Why document.title? It's the simplest channel to pass data from the browser JS context back to the shell. The title has a practical limit of a few KB, which is enough for most API responses.

3.4 Making Authenticated API Calls

Since the browser is already logged in, we can use fetch() with credentials: "include" to make API calls that automatically include all cookies — even HttpOnly ones that JavaScript can't read directly.

// This runs inside the logged-in Chrome — cookies are sent automatically
fetch("/api/endpoint.json", {
    credentials: "include"  // This is the magic — sends all cookies
})
Enter fullscreen mode Exit fullscreen mode

For POST requests (posting, commenting, voting), most sites require a CSRF token:

// Step 1: Get CSRF/modhash token
let me = await fetch("/api/me.json", {credentials: "include"}).then(r => r.json());
let csrf = me.data.modhash;  // Reddit calls it "modhash"

// Step 2: POST with the token
await fetch("/api/comment", {
    method: "POST",
    credentials: "include",
    headers: {"Content-Type": "application/x-www-form-urlencoded"},
    body: `thing_id=t3_abc123&text=Hello&uh=${csrf}&api_type=json`
});
Enter fullscreen mode Exit fullscreen mode

3.5 JXA (JavaScript for Automation) — Avoiding Escaping Hell

AppleScript has painful string escaping (nested quotes are a nightmare). JXA is macOS's JavaScript-based alternative that solves this:

osascript -l JavaScript -e '
var chrome = Application("Google Chrome");
var tab = chrome.windows[0].activeTab;
tab.execute({javascript: "(" + function() {
    // Write normal JavaScript here — no escaping needed!
    var data = {hello: "world"};
    document.title = JSON.stringify(data);
} + ")();"});
'
Enter fullscreen mode Exit fullscreen mode

The trick: define a JS function, .toString() it (via + concatenation), and pass that string to execute. This means you write clean JavaScript without worrying about AppleScript string escaping.

3.6 Targeting Specific Tabs

# Active tab of first window (most common)
tell active tab of first window

# Specific tab by index
tell tab 3 of first window

# Specific window
tell active tab of window 2

# Find tab by URL
tell application "Google Chrome"
    repeat with t in tabs of first window
        if URL of t contains "reddit.com" then
            execute javascript "document.title" in t
        end if
    end repeat
end tell
Enter fullscreen mode Exit fullscreen mode

3.7 Opening New Tabs

osascript -e '
tell application "Google Chrome"
    tell first window
        make new tab with properties {URL:"https://www.reddit.com"}
    end tell
end tell'
Enter fullscreen mode Exit fullscreen mode

Part 4: Our Reddit Automation — The Full Story

The Journey (What We Tried)

We needed to automate Reddit posting and commenting. Here's every approach we tried, in order:

Attempt 1: Playwright MCP Browser

  • Opened a new Playwright browser
  • Navigated to reddit.com
  • Result: Reddit returned 403 "Blocked" — detected automation immediately
  • Even with injected cookies via context.addCookies(), Reddit still blocked the page

Attempt 2: Cookie Injection

  • User exported all cookies (including HttpOnly) from Cookie Editor extension
  • Injected 14 cookies into Playwright via context.addCookies()
  • Navigated to reddit.com
  • Result: Still 403 "Blocked" — cookie injection doesn't fix the browser fingerprint

Attempt 3: Chrome DevTools MCP

  • Launched Chrome with --remote-debugging-port=9222
  • Chrome error: "DevTools remote debugging requires a non-default data directory"
  • Used --user-data-dir=/tmp/chrome-debug → works but fresh profile, no cookies
  • Could navigate to Reddit (not blocked!) but couldn't log in
  • Tried injecting HttpOnly cookies via document.cookie — can't set HttpOnly from JS
  • Result: Dead end — can't authenticate

Attempt 4: Reddit API via curl

  • Used token_v2 (JWT from browser cookies) as Bearer token
  • First call: HTTP 200 — it works!
  • Second call: HTTP 200 (features only, no user data)
  • Third call: 403 "Blocked" — IP rate-limited
  • Result: Token works but IP gets blocked after a few rapid requests

Attempt 5: AppleScript Chrome Control (THE WIN)

  • Remembered macOS can control Chrome via AppleScript
  • One prerequisite: enable "Allow JavaScript from Apple Events"
  • Needed Chrome restart after enabling
  • First test: execute javascript "document.title"works!
  • Navigate to reddit.com → not blocked (it's the real Chrome)
  • fetch("/api/me.json")returns user data (already logged in!)
  • Post comment via fetch("/api/comment")comment posted successfully
  • Result: Full automation with zero detection

The Working Pipeline

Step 1: Navigate to reddit.com (if not already there)
    osascript → Chrome → set URL to reddit.com

Step 2: Scan for opportunities
    osascript → Chrome → fetch("/r/SideProject/rising.json")
    → Filter: score > 2, comments < 15

Step 3: Generate comment
    Claude Code / GPT-5.2 → draft value-first comment

Step 4: Human review
    Show comment to user → user approves

Step 5: Get CSRF token (modhash)
    osascript → Chrome → fetch("/api/me.json") → extract modhash

Step 6: Post comment
    osascript → Chrome → fetch("/api/comment", {method: "POST", body: ...})

Step 7: Verify
    Read API response → confirm comment ID and permalink
Enter fullscreen mode Exit fullscreen mode

Actual Commands Used

# Check login status
osascript -e '..execute javascript "fetch(\"/api/me.json\",{credentials:\"include\"}).then(r=>r.json()).then(d=>{document.title=\"USER:\"+JSON.stringify({name:d.data.name,karma:d.data.total_karma})})"'
# → USER:{"name":"BP041","karma":35}

# Scan rising posts
osascript -e '..execute javascript "fetch(\"/r/SideProject/rising.json?limit=5\",{credentials:\"include\"}).then(r=>r.json()).then(d=>{let posts=d.data.children.map(c=>({t:c.data.title.substring(0,60),s:c.data.score,c:c.data.num_comments,id:c.data.name}));document.title=\"POSTS:\"+JSON.stringify(posts)})"'

# Get modhash
osascript -e '..execute javascript "fetch(\"/api/me.json\",{credentials:\"include\"}).then(r=>r.json()).then(d=>{document.title=\"MH:\"+d.data.modhash})"'

# Post comment (using JXA to avoid escaping issues)
osascript -l JavaScript -e '
var chrome = Application("Google Chrome");
var tab = chrome.windows[0].activeTab;
tab.execute({javascript: "(" + function() {
    var body = new URLSearchParams({
        thing_id: "t3_1qzoq6p",
        text: "Your comment here",
        uh: "modhash_here",
        api_type: "json"
    });
    fetch("/api/comment", {
        method: "POST",
        credentials: "include",
        headers: {"Content-Type": "application/x-www-form-urlencoded"},
        body: body.toString()
    }).then(r=>r.json()).then(d=>{document.title="POSTED:"+JSON.stringify(d)});
} + ")();"});
'
Enter fullscreen mode Exit fullscreen mode

Part 5: Beyond Reddit — This Works Everywhere

The Pattern is Universal

Any website that you can use in Chrome, you can automate with this method:

Platform What you can automate
Reddit Post, comment, upvote, browse, subscribe
Twitter/X Tweet, reply, like, retweet, DM
LinkedIn Post, comment, connect, message
Instagram Like, comment (web version)
GitHub Create issues, PRs, review code
Any SaaS Any action you can do in the browser

The Same Three Steps Apply

  1. Navigate to the site (or verify you're already there)
  2. Execute fetch() calls using credentials: "include"
  3. Read results via document.title

For Sites with Complex SPAs

Some sites (Twitter, LinkedIn) are heavy SPAs where the API isn't as clean as Reddit's. For these, you can also use DOM manipulation:

// Click a button
document.querySelector('[data-testid="tweetButton"]').click()

// Fill an input
document.querySelector('[data-testid="tweetTextarea"]').innerText = "Hello world"

// Trigger React's change event
let el = document.querySelector('input');
let ev = new Event('input', {bubbles: true});
el.value = 'new value';
el.dispatchEvent(ev);
Enter fullscreen mode Exit fullscreen mode

Part 6: Chrome Multi-Profile Bug & The System Events Fallback

The Problem

Chrome on macOS supports multiple user profiles (e.g. "Haoyang", "Work", "Personal"). When a user has multiple profiles, AppleScript's tell application "Google Chrome" can only see windows from ONE profile — and it's not always the one you need.

Symptoms:

osascript -e 'tell application "Google Chrome" to return count of windows'
# Returns: 0
# But the user clearly has Chrome windows open!
Enter fullscreen mode Exit fullscreen mode

The user sees multiple Chrome windows with tabs, logged into various sites. But AppleScript reports 0 windows. This happens because Chrome's AppleScript interface only exposes windows from one profile context.

The Discovery

While tell application "Google Chrome" is blind, System Events can see ALL Chrome windows regardless of profile:

osascript -e '
tell application "System Events"
    tell process "Google Chrome"
        return name of window 1
    end tell
end tell'
# Returns: "Reddit - The heart of the internet - Google Chrome - Haoyang"
# ^^^^ System Events sees it!
Enter fullscreen mode Exit fullscreen mode

The Solution: Console + Clipboard

Since we can't use execute javascript on an invisible-to-AppleScript window, we use a keyboard-based approach:

  1. Copy JavaScript code to clipboard via pbcopy
  2. Open Chrome Console via keyboard shortcut Cmd+Option+J
  3. Select all in console Cmd+A (clear previous)
  4. Paste from clipboard Cmd+V
  5. Execute by pressing Enter
  6. Close console Cmd+Option+J
  7. Read document.title via System Events

Implementation

# Step 1: Copy JS to clipboard (use python3 to avoid shell escaping)
python3 -c "
import subprocess
js = '''(async()=>{
    let r = await fetch('/api/me.json', {credentials: 'include'});
    let d = await r.json();
    document.title = 'USER:' + JSON.stringify({name: d.data.name, karma: d.data.total_karma});
})()'''
subprocess.run(['pbcopy'], input=js.encode(), check=True)
"

# Step 2: Open console, paste, execute, close console
osascript -e '
tell application "System Events"
    tell process "Google Chrome"
        set frontmost to true
        delay 0.3
        -- Open JS console (Cmd+Option+J)
        key code 38 using {command down, option down}
        delay 1
        -- Select all (clear previous code)
        keystroke "a" using {command down}
        delay 0.2
        -- Paste JS from clipboard
        keystroke "v" using {command down}
        delay 0.5
        -- Press Enter to execute
        key code 36
        delay 0.3
        -- Close console
        key code 38 using {command down, option down}
    end tell
end tell'

# Step 3: Wait and read result from window title
sleep 3
osascript -e '
tell application "System Events"
    tell process "Google Chrome"
        return name of window 1
    end tell
end tell'
# Returns: "USER:{"name":"BP041","karma":37} - Google Chrome - Haoyang"
Enter fullscreen mode Exit fullscreen mode

POST Example (Posting a Reddit Comment)

python3 -c "
import subprocess
js = '''(async()=>{
    let me = await fetch('/api/me.json', {credentials: 'include'}).then(r=>r.json());
    let uh = me.data.modhash;
    let body = new URLSearchParams({
        thing_id: 't3_1qzhcyg',
        text: 'Your comment text here',
        uh: uh,
        api_type: 'json'
    });
    let r = await fetch('/api/comment', {
        method: 'POST',
        credentials: 'include',
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: body.toString()
    });
    let d = await r.json();
    let ok = d.json && d.json.errors.length === 0;
    document.title = 'RESULT:' + (ok ? 'SUCCESS' : 'FAIL:' + JSON.stringify(d.json.errors));
})()'''
subprocess.run(['pbcopy'], input=js.encode(), check=True)
"

osascript -e '
tell application "System Events"
    tell process "Google Chrome"
        set frontmost to true
        delay 0.3
        key code 38 using {command down, option down}
        delay 1
        keystroke "a" using {command down}
        delay 0.2
        keystroke "v" using {command down}
        delay 0.5
        key code 36
        delay 0.3
        key code 38 using {command down, option down}
    end tell
end tell'

sleep 5

osascript -e '
tell application "System Events"
    tell process "Google Chrome"
        return name of window 1
    end tell
end tell'
# Returns: "RESULT:SUCCESS - Google Chrome - Haoyang"
Enter fullscreen mode Exit fullscreen mode

Auto-Detection: Which Method to Use

#!/bin/bash
# Detect if Method 1 or Method 2 is needed
WINDOWS=$(osascript -e 'tell application "Google Chrome" to return count of windows' 2>/dev/null)

if [ "$WINDOWS" != "0" ] && [ -n "$WINDOWS" ]; then
    echo "Method 1: AppleScript execute javascript (direct, fast)"
else
    # Check if System Events can see Chrome windows
    SE_WINDOW=$(osascript -e 'tell application "System Events" to tell process "Google Chrome" to return name of window 1' 2>/dev/null)
    if [ -n "$SE_WINDOW" ]; then
        echo "Method 2: System Events + Console + Clipboard (multi-profile fallback)"
    else
        echo "No Chrome windows found at all"
    fi
fi
Enter fullscreen mode Exit fullscreen mode

Why This Happens

Chrome's AppleScript dictionary (Google Chrome.sdef) exposes windows per-profile. When Chrome starts, it associates AppleScript access with one profile context. Windows from other profiles exist at the macOS level (visible to System Events, Accessibility API, and the user) but are invisible to tell application "Google Chrome".

This is likely a Chrome bug, not a feature. But the System Events fallback is reliable and battle-tested.

Method Comparison

Method 1: execute javascript Method 2: System Events + Console
Speed Fast (~2s per call) Slower (~5s per call)
Reliability High (when it works) High (always works)
Multi-profile Breaks Works
Escaping Painful (use JXA) Easy (use python3 + pbcopy)
Read results title of active tab name of window 1 (includes " - Google Chrome - Profile")
Prerequisites Allow JS from Apple Events Allow JS from Apple Events + Console access

Part 7: Limitations and Gotchas

Known Limitations

Limitation Workaround
macOS only No workaround — AppleScript is Apple-only
Chrome must be running Script can launch Chrome: open -a "Google Chrome"
Must be logged in already Log in manually once, session persists
document.title size limit (~few KB) Chunk large responses, or use multiple calls
Async results need sleep/polling sleep 2 between execute and read
AppleScript string escaping is painful Use JXA (JavaScript for Automation) instead
One command at a time per tab Use multiple tabs for parallelism

Security Considerations

  • "Allow JavaScript from Apple Events" means ANY AppleScript can execute JS in your Chrome — be aware of this if you run untrusted scripts
  • Your browser cookies are used directly — treat this script with the same security as your browser
  • Don't hardcode sensitive data in scripts — use environment variables

Rate Limiting Best Practices

  • Wait 2+ seconds between API calls
  • Don't post more than 5 comments per session
  • Space out sessions (don't run hourly)
  • Monitor for 429/403 responses and back off

Part 8: Quick Reference

Cheat Sheet

# Is Chrome running?
pgrep -x "Google Chrome" && echo "Running" || echo "Not running"

# Launch Chrome
open -a "Google Chrome"

# Quit Chrome
osascript -e 'tell application "Google Chrome" to quit'

# Get current URL
osascript -e 'tell application "Google Chrome" to return URL of active tab of first window'

# Get page title
osascript -e 'tell application "Google Chrome" to return title of active tab of first window'

# Navigate
osascript -e 'tell application "Google Chrome" to tell active tab of first window to set URL to "https://example.com"'

# Execute JS (sync)
osascript -e 'tell application "Google Chrome" to tell active tab of first window to execute javascript "1+1"'

# Execute JS (async) — write to title, then read
osascript -e 'tell application "Google Chrome" to tell active tab of first window to execute javascript "fetch(\"/api/data\").then(r=>r.json()).then(d=>{document.title=JSON.stringify(d)})"'
sleep 2
osascript -e 'tell application "Google Chrome" to return title of active tab of first window'

# New tab
osascript -e 'tell application "Google Chrome" to tell first window to make new tab with properties {URL:"https://example.com"}'

# Count tabs
osascript -e 'tell application "Google Chrome" to return count of tabs of first window'

# JXA (for complex JS without escaping pain)
osascript -l JavaScript -e '
var chrome = Application("Google Chrome");
var tab = chrome.windows[0].activeTab;
tab.execute({javascript: "document.title"});
'
Enter fullscreen mode Exit fullscreen mode

The Golden Rule

If you can do it in Chrome DevTools Console, you can automate it with AppleScript. Same context, same cookies, same permissions. The website literally cannot tell the difference.

Discovered on 2026-02-09 while trying to automate Reddit posting. After Playwright, Selenium, Chrome DevTools Protocol, and curl all failed to bypass Reddit's anti-bot detection, AppleScript Chrome control worked on the first try — because it doesn't try to fake a browser. It IS the browser.

Top comments (0)