diff --git a/.gitignore b/.gitignore
index fcd6eada..c0be3b1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,3 +64,4 @@ sandbox/docker/claude/claude-proxy-*
sandbox/proxy/claude-proxy-linux-*
release/*
sandbox/TODO-VNC.md
+sandbox/docker/chrome/PLAN.md
diff --git a/sandbox/README.md b/sandbox/README.md
index 79c61fca..e6807bc1 100644
--- a/sandbox/README.md
+++ b/sandbox/README.md
@@ -37,13 +37,13 @@ The sandbox module enables Yao to safely run external AI coding agents (like Cla
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
-│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
-│ │ sandbox- │ │ sandbox- │ │ sandbox- │ │
-│ │ claude │ │ playwright │ │ desktop │ │
-│ │ (No VNC) │ │ (VNC) │ │ (VNC) │ │
-│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
-│ │ │ │ │
-│ ──────┴───────────────┴───────────────┴──── │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ sandbox- │ │ sandbox- │ │ sandbox- │ │ sandbox- │ │
+│ │ claude │ │ browser │ │ desktop │ │ chrome │ │
+│ │ (No VNC) │ │ (VNC) │ │ (VNC) │ │ (VNC) │ │
+│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
+│ │ │ │ │ │
+│ ─────┴────────────┴────────────┴────────────┴──── │
│ Unix Socket IPC │
│ (one socket per container) │
└───────────────────────────────────────────────────────────────┘
@@ -62,6 +62,7 @@ cd sandbox/docker
# Build VNC-enabled images
./build.sh browser # Browser (Playwright) + Fluxbox + VNC
./build.sh desktop # XFCE Desktop + VNC
+./build.sh chrome # Real Chrome + CDP + VNC (amd64 only)
# Build all images
./build.sh all
@@ -134,6 +135,7 @@ When enabled, VNC ports (6080, 5900) are automatically mapped to random availabl
| `yaoapp/sandbox-claude:full` | ❌ | + Go 1.23 |
| `yaoapp/sandbox-claude-browser:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) |
| `yaoapp/sandbox-claude-desktop:latest` | ✅ | + XFCE Desktop, VNC (~3.1GB) |
+| `yaoapp/sandbox-claude-chrome:latest` | ✅ | + Real Chrome, CDP, PyAutoGUI, VNC (~3.4GB, amd64 only) |
## IPC Communication
@@ -174,6 +176,9 @@ sandbox/
│ ├── claude/
│ ├── browser/ # Browser (Playwright) + VNC image
│ ├── desktop/ # XFCE Desktop + VNC image
+│ ├── chrome/ # Real Chrome + CDP + VNC image (amd64 only)
+│ │ ├── config/ # Chrome preferences, stealth scripts
+│ │ └── tests/ # LLM-driven browser automation demos
│ ├── vnc/ # Shared VNC scripts
│ └── build.sh
├── ipc/ # IPC system
diff --git a/sandbox/docker/build.sh b/sandbox/docker/build.sh
index 8b727582..2e3ea74b 100755
--- a/sandbox/docker/build.sh
+++ b/sandbox/docker/build.sh
@@ -67,6 +67,25 @@ setup_buildx() {
fi
}
+# Build single-arch image (amd64 only, for Chrome which has no arm64 build)
+build_amd64() {
+ local IMAGE_NAME=$1
+ local DOCKERFILE=$2
+ local PUSH_FLAG=$3
+
+ echo ""
+ echo "=== Building $IMAGE_NAME (linux/amd64 only) ==="
+
+ BUILD_ARGS="--platform linux/amd64 -t ${REGISTRY}/${IMAGE_NAME}:latest"
+ if [ "$PUSH_FLAG" = "true" ]; then
+ BUILD_ARGS="$BUILD_ARGS --push"
+ else
+ BUILD_ARGS="$BUILD_ARGS --load"
+ fi
+
+ docker buildx build $BUILD_ARGS -f "$DOCKERFILE" .
+}
+
# Build multi-arch image
build_multiarch() {
local IMAGE_NAME=$1
@@ -121,6 +140,11 @@ case $TOOL in
echo "=== Building Claude Desktop image ==="
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
;;
+ chrome)
+ echo ""
+ echo "=== Building Claude Chrome image (amd64 only) ==="
+ build_amd64 "sandbox-claude-chrome" "chrome/Dockerfile" "$PUSH"
+ ;;
cursor)
echo ""
echo "=== Building Cursor images ==="
@@ -135,17 +159,20 @@ case $TOOL in
# Claude VNC variants
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
+ # Chrome (amd64 only - Google Chrome has no arm64 Linux build)
+ build_amd64 "sandbox-claude-chrome" "chrome/Dockerfile" "$PUSH"
# Cursor (uncomment when ready)
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
;;
*)
echo "Unknown tool: $TOOL"
- echo "Usage: $0 [claude|claude-vnc|browser|desktop|cursor|all] [true|false]"
+ echo "Usage: $0 [claude|claude-vnc|browser|desktop|chrome|cursor|all] [true|false]"
echo " $0 claude # Build Claude images locally"
echo " $0 claude true # Build and push Claude images"
echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)"
echo " $0 browser # Build Claude Browser image only"
echo " $0 desktop # Build Claude Desktop image only"
+ echo " $0 chrome # Build Claude Chrome image (amd64 only)"
echo " $0 all true # Build and push all images"
exit 1
;;
@@ -174,11 +201,15 @@ if [ "$PUSH" = "true" ]; then
desktop)
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
;;
+ chrome)
+ echo " - ${REGISTRY}/sandbox-claude-chrome:latest"
+ ;;
all)
echo " - ${REGISTRY}/sandbox-claude:latest"
echo " - ${REGISTRY}/sandbox-claude-full:latest"
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
+ echo " - ${REGISTRY}/sandbox-claude-chrome:latest"
;;
esac
fi
diff --git a/sandbox/docker/chrome/Dockerfile b/sandbox/docker/chrome/Dockerfile
new file mode 100644
index 00000000..7d456a1b
--- /dev/null
+++ b/sandbox/docker/chrome/Dockerfile
@@ -0,0 +1,166 @@
+# Claude sandbox with real Google Chrome + anti-detection stealth
+# Image: sandbox-claude-chrome
+# Base: sandbox-claude (Ubuntu 24.04 + Node.js + Python + Claude CLI)
+# Adds: Xvfb + x11vnc + noVNC + Fluxbox + Real Chrome + Patchright + PyAutoGUI
+#
+# Anti-bot detection browser environment for web research tasks
+# amd64 architecture only (Google Chrome has no official arm64 Linux build)
+
+ARG REGISTRY=yaoapp
+FROM ${REGISTRY}/sandbox-claude:latest
+
+USER root
+
+# ============================================
+# 1. VNC + Window Manager (same as browser image)
+# ============================================
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ # Sudo for sandbox user
+ sudo \
+ # Virtual display
+ xvfb \
+ # VNC server
+ x11vnc \
+ # noVNC (HTML5 VNC client) and websockify
+ novnc \
+ python3-websockify \
+ # Minimal window manager
+ fluxbox \
+ # Background/wallpaper utilities
+ feh \
+ imagemagick \
+ # Fonts (required for proper browser rendering)
+ fonts-liberation \
+ fonts-noto-cjk \
+ fonts-noto-color-emoji \
+ # X11 utilities
+ x11-utils \
+ xdotool \
+ # Audio (for video playback)
+ pulseaudio \
+ # PyAutoGUI X11 dependencies
+ python3-tk \
+ python3-dev \
+ scrot \
+ # Misc
+ xterm \
+ && rm -rf /var/lib/apt/lists/*
+
+# Configure passwordless sudo for sandbox user
+RUN echo "sandbox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/sandbox && \
+ chmod 0440 /etc/sudoers.d/sandbox
+
+# ============================================
+# 2. Real Google Chrome (amd64 only)
+# ============================================
+RUN curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \
+ | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg && \
+ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] \
+ http://dl.google.com/linux/chrome/deb/ stable main" \
+ > /etc/apt/sources.list.d/google-chrome.list && \
+ apt-get update && apt-get install -y google-chrome-stable && \
+ rm -rf /var/lib/apt/lists/*
+
+# ============================================
+# 3. Playwright system deps (for Patchright compatibility)
+# ============================================
+RUN npx playwright install-deps chromium || true
+
+# ============================================
+# 4. Python anti-detection libraries
+# ============================================
+USER sandbox
+
+# Install Patchright (stealth Playwright fork) + PyAutoGUI + stealth libs
+RUN pip install --user --break-system-packages \
+ patchright \
+ pyautogui \
+ playwright-stealth \
+ playwright && \
+ # Install Patchright browser deps (uses system Chrome, no Chromium download)
+ python3 -m patchright install chromium || true
+
+# Install Node.js Playwright + stealth plugins
+RUN npm install -g playwright playwright-extra puppeteer-extra-plugin-stealth
+
+USER root
+
+# ============================================
+# 5. Copy config files
+# ============================================
+RUN mkdir -p /usr/local/share/yao
+
+# VNC startup scripts (shared with browser/desktop)
+COPY vnc/start-vnc.sh /usr/local/bin/start-vnc.sh
+COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh
+
+# Chrome-specific config files
+COPY chrome/config/setup-fluxbox.sh /usr/local/bin/setup-fluxbox.sh
+COPY chrome/config/chrome-stealth.sh /usr/local/bin/chrome-stealth
+COPY chrome/config/stealth-init.js /usr/local/share/yao/stealth-init.js
+COPY chrome/config/chrome-preferences.json /usr/local/share/yao/chrome-preferences.json
+
+# Reuse yao-logo from browser image
+COPY browser/config/yao-logo.png /usr/local/share/yao/yao-logo.png
+
+RUN chmod +x /usr/local/bin/start-vnc.sh \
+ /usr/local/bin/entrypoint.sh \
+ /usr/local/bin/setup-fluxbox.sh \
+ /usr/local/bin/chrome-stealth
+
+# ============================================
+# 6. Default Chrome profile + X11 auth
+# ============================================
+RUN mkdir -p /home/sandbox/.config/google-chrome/Default && \
+ cp /usr/local/share/yao/chrome-preferences.json \
+ /home/sandbox/.config/google-chrome/Default/Preferences && \
+ # Mark first run as done
+ touch /home/sandbox/.config/google-chrome/First\ Run && \
+ # Create .Xauthority for PyAutoGUI (Xvfb runs without auth)
+ touch /home/sandbox/.Xauthority && \
+ chown -R sandbox:sandbox /home/sandbox/.config/google-chrome /home/sandbox/.Xauthority
+
+# ============================================
+# 7. Environment variables
+# ============================================
+ENV DISPLAY=:99
+ENV VNC_PORT=5900
+ENV NOVNC_PORT=6080
+ENV RESOLUTION=1920x1080x24
+ENV SANDBOX_VNC_ENABLED=true
+ENV SANDBOX_DESKTOP=fluxbox
+
+# Node.js environment
+ENV NODE_PATH=/home/sandbox/.npm-global/lib/node_modules
+
+# Timezone
+ENV TZ=America/New_York
+
+# Expose VNC ports (internal use only, accessed via proxy)
+EXPOSE 5900 6080
+
+USER sandbox
+WORKDIR /workspace
+
+# ============================================
+# 8. Verify installations
+# ============================================
+RUN echo "=== Verifying installations ===" && \
+ google-chrome-stable --version && \
+ node --version && \
+ npm --version && \
+ python3 --version && \
+ python3 -c "from patchright.sync_api import sync_playwright; print('Patchright: OK')" && \
+ python3 -c "from playwright.sync_api import sync_playwright; print('Playwright: OK')" && \
+ python3 -c "from playwright_stealth import Stealth; print('Playwright-Stealth: OK')" && \
+ pip3 show pyautogui | head -2 && echo "PyAutoGUI: OK" && \
+ which fluxbox && \
+ which x11vnc && \
+ which Xvfb && \
+ which chrome-stealth && \
+ test -f /usr/local/share/yao/stealth-init.js && \
+ test -f /home/sandbox/.config/google-chrome/Default/Preferences && \
+ echo "=== All installations verified ==="
+
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+CMD ["sleep", "infinity"]
diff --git a/sandbox/docker/chrome/config/chrome-preferences.json b/sandbox/docker/chrome/config/chrome-preferences.json
new file mode 100644
index 00000000..dcc60d77
--- /dev/null
+++ b/sandbox/docker/chrome/config/chrome-preferences.json
@@ -0,0 +1,34 @@
+{
+ "browser": {
+ "enabled_labs_experiments": ["disable-search-engine-collection@2"],
+ "check_default_browser": false,
+ "has_seen_welcome_page": true
+ },
+ "profile": {
+ "default_content_setting_values": {
+ "notifications": 2
+ }
+ },
+ "credentials_enable_service": false,
+ "translate": {
+ "enabled": false
+ },
+ "intl": {
+ "accept_languages": "en-US,en"
+ },
+ "distribution": {
+ "skip_first_run_ui": true,
+ "show_welcome_page": false,
+ "import_bookmarks": false,
+ "import_history": false,
+ "import_search_engine": false,
+ "suppress_first_run_bubble": true,
+ "do_not_create_desktop_shortcut": true,
+ "do_not_create_quick_launch_shortcut": true,
+ "do_not_create_taskbar_shortcut": true,
+ "do_not_launch_chrome": true,
+ "do_not_register_for_update_launch": true,
+ "make_chrome_default": false,
+ "make_chrome_default_for_user": false
+ }
+}
diff --git a/sandbox/docker/chrome/config/stealth-init.js b/sandbox/docker/chrome/config/stealth-init.js
new file mode 100644
index 00000000..b0db58d5
--- /dev/null
+++ b/sandbox/docker/chrome/config/stealth-init.js
@@ -0,0 +1,44 @@
+// Yao Sandbox - Chrome Stealth Initialization Script
+// Injected before page load to mask automation fingerprints
+// Location: /usr/local/share/yao/stealth-init.js
+
+// Remove webdriver flag
+Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
+
+// Fake chrome.runtime (Chrome Extension API)
+if (!window.chrome) window.chrome = {};
+if (!window.chrome.runtime) {
+ window.chrome.runtime = {
+ connect: function() {},
+ sendMessage: function() {},
+ onMessage: { addListener: function() {} },
+ id: undefined
+ };
+}
+
+// Fake navigator.plugins (simulate Chrome default plugins)
+Object.defineProperty(navigator, 'plugins', {
+ get: () => [
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' }
+ ]
+});
+
+// Fake navigator.languages
+Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
+
+// Fix permissions API behavior
+const originalQuery = window.navigator.permissions.query;
+window.navigator.permissions.query = (parameters) =>
+ parameters.name === 'notifications'
+ ? Promise.resolve({ state: Notification.permission })
+ : originalQuery(parameters);
+
+// WebGL vendor/renderer spoofing
+const getParameter = WebGLRenderingContext.prototype.getParameter;
+WebGLRenderingContext.prototype.getParameter = function(parameter) {
+ if (parameter === 37445) return 'Google Inc. (Intel)'; // UNMASKED_VENDOR_WEBGL
+ if (parameter === 37446) return 'ANGLE (Intel, Mesa Intel(R) UHD Graphics, OpenGL 4.6)'; // UNMASKED_RENDERER_WEBGL
+ return getParameter.call(this, parameter);
+};
diff --git a/sandbox/docker/chrome/tests/README.md b/sandbox/docker/chrome/tests/README.md
new file mode 100644
index 00000000..644ec6e0
--- /dev/null
+++ b/sandbox/docker/chrome/tests/README.md
@@ -0,0 +1,105 @@
+# Chrome Browser Automation Demo Tests
+
+Example scripts demonstrating browser automation inside the `sandbox-claude-chrome` Docker image.
+
+## Scripts
+
+| Script | Description |
+|--------|-------------|
+| `demo-llm-vision.py` | **LLM-driven universal automation** — works with any search engine, no hardcoded selectors. LLM reads page DOM and decides what to click. |
+| `demo-baidu.py` | Baidu search demo — hardcoded selectors |
+| `demo-duckduckgo.py` | DuckDuckGo search demo — hardcoded selectors |
+
+## demo-llm-vision.py
+
+The main demo. Uses a layered architecture where each component does what it's best at:
+
+```
+LLM reads HTML → returns CSS selectors → DOM locates elements → CDP clicks
+```
+
+- **Playwright**: Opens pages, extracts DOM, keyboard input
+- **LLM**: Reads page structure, returns CSS selectors for target elements (any cheap text model works)
+- **DOM**: Uses LLM's selectors to get precise bounding boxes
+- **CDP**: Chrome DevTools Protocol mouse events (`isTrusted=true`) for clicking
+
+No hardcoded selectors — LLM figures out the page structure dynamically. Works with Google, Bing, Baidu, DuckDuckGo, Sogou, and any other search engine.
+
+### Key Features
+
+- **Concurrent LLM Race**: DOM is split into chunks, sent to LLM concurrently. First valid response wins — faster than sequential.
+- **CDP Click**: Uses `Input.dispatchMouseEvent` via Chrome DevTools Protocol. Coordinates match `bounding_box()` exactly, no offset issues.
+- **Ctrl+Click New Tab**: Search results open in new tabs, keeping the results list intact for clicking more links.
+- **Fallback Chain**: CDP click → PyAutoGUI OS-level click → Playwright `.click()` → form submit. Always gets through.
+
+### Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `LLM_API_KEY` | API key for the LLM service |
+| `LLM_API_BASE` | OpenAI-compatible endpoint URL |
+| `LLM_MODEL` | Model name (e.g. `gpt-4o-mini`) |
+
+### Quick Start
+
+```bash
+# Start the container
+docker run -d --name chrome-test \
+ --platform linux/amd64 \
+ -p 6080:6080 \
+ yaoapp/sandbox-claude-chrome:latest
+
+# Wait for VNC to start
+sleep 5
+
+# Copy the script
+docker cp tests/demo-llm-vision.py chrome-test:/workspace/
+
+# Run with any search engine
+docker exec \
+ -e LLM_API_KEY="your-key" \
+ -e LLM_API_BASE="https://api.openai.com/v1/" \
+ -e LLM_MODEL="gpt-4o-mini" \
+ chrome-test bash -c \
+ 'DISPLAY=:99 python3 /workspace/demo-llm-vision.py "https://www.bing.com" "Yao App Engine"'
+```
+
+Open `http://localhost:6080` in your browser to watch the automation in real-time via VNC.
+
+### Tested Search Engines
+
+| Engine | Status | Notes |
+|--------|--------|-------|
+| Bing | Passed | gpt-4o-mini, ~100s |
+| Sogou | Passed | gpt-4o-mini, ~83s |
+| DuckDuckGo | Passed | gpt-4o-mini, ~87s |
+| Baidu | Passed | glm-4-7, ~160s |
+| Google | Passed | May show CAPTCHA on shared IPs |
+
+### Flow
+
+```
+Phase 1 Open search engine homepage
+Phase 2 [LLM Race] Analyze homepage DOM → get input/button selectors
+Phase 3 [CDP] Click search input, type query
+Phase 4 [CDP] Click search button (fallback: Enter key → form submit)
+Phase 5 [LLM Race] Analyze results page DOM → get link selector
+Phase 7+ [CDP Ctrl+Click] Open results in new tabs, screenshot, close
+```
+
+## Screenshots
+
+Each demo saves screenshots to `/workspace/` at key steps:
+
+| File | Content |
+|------|---------|
+| `llm-01-homepage.png` | Search engine homepage |
+| `llm-02-typed.png` | Query typed in search box |
+| `llm-03-results.png` | Search results page |
+| `llm-detail.png` | Result detail page (new tab) |
+
+## Notes
+
+- **Google** may show reCAPTCHA due to IP-based rate limiting. Use a clean IP or proxy.
+- **Model choice**: `gpt-4o-mini` recommended for speed. Slower models (e.g. `glm-4-7`) may timeout on large DOMs.
+- **Concurrent Race** splits DOM into ~2000-char chunks and sends all chunks + full DOM to LLM simultaneously. First valid JSON response wins.
diff --git a/sandbox/docker/chrome/tests/demo-baidu.py b/sandbox/docker/chrome/tests/demo-baidu.py
new file mode 100644
index 00000000..fef04c66
--- /dev/null
+++ b/sandbox/docker/chrome/tests/demo-baidu.py
@@ -0,0 +1,349 @@
+"""
+Baidu Search Demo — PyAutoGUI OS-level Mouse + Smart Keyboard Fallback
+
+Demonstrates anti-detection browser automation inside sandbox-claude-chrome:
+ 1. Open Baidu homepage
+ 2. Click search box with PyAutoGUI (real OS mouse event)
+ 3. Type query with PyAutoGUI keyboard (auto-fallback to Playwright if needed)
+ 4. Submit search
+ 5. Click first search result (PyAutoGUI mouse) → view detail page
+ 6. Go back
+ 7. Click second search result (PyAutoGUI mouse) → view detail page
+
+All mouse clicks are OS-level X11 events via PyAutoGUI — undetectable by websites.
+
+Prerequisites:
+ - Running inside sandbox-claude-chrome container
+ - DISPLAY=:99 (Xvfb virtual display)
+ - VNC optional for live observation (http://localhost:6080)
+
+Usage:
+ DISPLAY=:99 python3 demo-baidu.py
+"""
+
+from playwright.sync_api import sync_playwright
+from playwright_stealth import Stealth
+import pyautogui
+import time
+import random
+import os
+
+# ---------------------------------------------------------------------------
+# PyAutoGUI config
+# ---------------------------------------------------------------------------
+pyautogui.FAILSAFE = False
+pyautogui.PAUSE = 0.1
+
+# Screenshot output directory
+SCREENSHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/workspace")
+
+
+# ---------------------------------------------------------------------------
+# Human-like helpers
+# ---------------------------------------------------------------------------
+def human_move(x, y):
+ """Move mouse with randomized speed to simulate human behavior."""
+ duration = random.uniform(0.4, 0.8)
+ pyautogui.moveTo(x, y, duration=duration)
+ time.sleep(random.uniform(0.1, 0.3))
+
+
+def human_click(x, y):
+ """Move to (x, y) then click — mimics a real user click."""
+ human_move(x, y)
+ time.sleep(random.uniform(0.05, 0.15))
+ pyautogui.click()
+ time.sleep(random.uniform(0.2, 0.5))
+
+
+def smart_type(element, text):
+ """Type text with PyAutoGUI first; fallback to Playwright if it didn't land.
+
+ On native amd64 Linux, PyAutoGUI keyboard works perfectly.
+ On ARM Mac (Rosetta 2), X11 keyboard events may not reach Chrome,
+ so we detect and automatically fallback to Playwright's type().
+ """
+ # Attempt PyAutoGUI keyboard (OS-level X11 events)
+ for ch in text:
+ pyautogui.press(ch)
+ time.sleep(random.uniform(0.05, 0.12))
+ time.sleep(0.5)
+
+ # Verify input landed
+ actual = element.input_value()
+ if actual and len(actual) >= len(text) * 0.8:
+ print(" Keyboard: PyAutoGUI (OS-level) ✓", flush=True)
+ return
+
+ # Fallback: Playwright type()
+ print(" PyAutoGUI keyboard didn't land — fallback to Playwright", flush=True)
+ element.fill("")
+ element.type(text, delay=80)
+ actual = element.input_value()
+ print(" Keyboard: Playwright fallback — '{}'".format(actual), flush=True)
+
+
+def find_element(page, selectors, min_width=50, timeout=2000):
+ """Try multiple CSS selectors, return (element, bounding_box) or (None, None)."""
+ for selector in selectors:
+ try:
+ el = page.locator(selector).first
+ box = el.bounding_box(timeout=timeout)
+ if box and box["width"] >= min_width:
+ return el, box
+ except Exception:
+ continue
+ return None, None
+
+
+def find_results(page, selectors, min_count=2, min_width=100):
+ """Find clickable search result elements with bounding boxes.
+
+ For Baidu: results are
.
+ We need the element — it's the actual clickable link.
+ """
+ results = []
+ for selector in selectors:
+ elements = page.locator(selector).all()
+ for el in elements[:10]:
+ try:
+ box = el.bounding_box(timeout=1000)
+ title = el.text_content().strip()
+ if box and box["width"] >= min_width and title and len(title) > 5:
+ if not any(r["title"] == title for r in results):
+ results.append({"box": box, "title": title, "el": el})
+ except Exception:
+ pass
+ if len(results) >= min_count:
+ break
+ return results
+
+
+def click_result(ctx, page, result, label):
+ """Click a search result using PyAutoGUI and handle new tab navigation.
+
+ Baidu results have target=_blank, so clicking opens a new tab.
+ We scroll the element into view first, then use PyAutoGUI for the OS-level click.
+ """
+ el = result["el"]
+
+ # Scroll element to a safe click zone.
+ # Baidu results page has a tall fixed search bar at the top (~150px).
+ # We need the element at y > 300 to avoid clicking the search input.
+ #
+ # Strategy: use PyAutoGUI mouse wheel scroll (real OS event) to position
+ # the element in the middle of the viewport, then re-read coordinates.
+ el.scroll_into_view_if_needed(timeout=3000)
+ time.sleep(0.3)
+ box = el.bounding_box(timeout=2000)
+
+ if box and box["y"] < 300:
+ # Element is too close to top — behind the fixed search bar.
+ # Use Playwright mouse.wheel to scroll page UP (negative deltaY)
+ # so the element moves DOWN in the viewport to a safe y > 350.
+ delta = int(box["y"]) - 400 # negative value scrolls page up
+ print(" Scrolling page (delta={}) to clear fixed header (y={})".format(
+ delta, int(box["y"])), flush=True)
+ page.mouse.wheel(0, delta)
+ time.sleep(0.8)
+
+ # Re-read bounding box after scroll
+ box = el.bounding_box(timeout=2000)
+ if not box:
+ print(" ⚠ Lost element after scroll", flush=True)
+ return False
+
+ # Click the left portion of the link text (more reliable than center)
+ rx = int(box["x"] + min(box["width"] * 0.3, 150))
+ ry = int(box["y"] + box["height"] / 2)
+ print(" [PyAutoGUI] Clicking at ({},{})".format(rx, ry), flush=True)
+
+ pages_before = len(ctx.pages)
+ human_click(rx, ry)
+
+ # Wait for new tab — Baidu links have target=_blank, so clicking should
+ # open a new tab. Give it enough time for the Baidu redirect.
+ for _ in range(10):
+ page.wait_for_timeout(500)
+ if len(ctx.pages) > pages_before:
+ break
+
+ if len(ctx.pages) > pages_before:
+ target = ctx.pages[-1]
+ target.wait_for_timeout(6000)
+ title = target.title()
+ url = target.url
+ print(" [New Tab] {} | {}".format(title[:50], url[:80]), flush=True)
+ screenshot(target, label)
+ target.close()
+ page.wait_for_timeout(1000)
+ return True
+ else:
+ # Check if URL changed (same-tab navigation via Baidu redirect)
+ current_url = page.url
+ if "baidu.com/s?" not in current_url:
+ page.wait_for_timeout(5000)
+ print(" Landed: {} | {}".format(page.title()[:50], page.url[:80]), flush=True)
+ screenshot(page, label)
+ print(" [PyAutoGUI] Going back...", flush=True)
+ pyautogui.hotkey("alt", "Left")
+ page.wait_for_timeout(3000)
+ return True
+ else:
+ print(" ⚠ Click didn't navigate — still on search page", flush=True)
+ print(" Trying Playwright click as fallback...", flush=True)
+ el.click(timeout=5000)
+ page.wait_for_timeout(3000)
+ if len(ctx.pages) > pages_before:
+ target = ctx.pages[-1]
+ target.wait_for_timeout(6000)
+ print(" [New Tab via Playwright] {} | {}".format(
+ target.title()[:50], target.url[:80]), flush=True)
+ screenshot(target, label)
+ target.close()
+ page.wait_for_timeout(1000)
+ return True
+ elif "baidu.com/s?" not in page.url:
+ page.wait_for_timeout(5000)
+ print(" [Playwright] Landed: {}".format(page.title()[:50]), flush=True)
+ screenshot(page, label)
+ pyautogui.hotkey("alt", "Left")
+ page.wait_for_timeout(3000)
+ return True
+ return False
+
+
+def screenshot(page, name):
+ """Save screenshot to SCREENSHOT_DIR."""
+ path = os.path.join(SCREENSHOT_DIR, name)
+ page.screenshot(path=path)
+ print(" Screenshot: " + path, flush=True)
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+def main():
+ print("=" * 60, flush=True)
+ print(" Baidu Search — PyAutoGUI OS-Level Demo", flush=True)
+ print("=" * 60, flush=True)
+
+ with Stealth().use_sync(sync_playwright()) as p:
+ browser = p.chromium.launch(
+ channel="chrome",
+ headless=False,
+ args=[
+ "--no-sandbox",
+ "--disable-blink-features=AutomationControlled",
+ "--disable-dev-shm-usage",
+ "--window-size=1920,1080",
+ "--window-position=0,0",
+ ],
+ )
+ ctx = browser.new_context(
+ viewport={"width": 1920, "height": 1080},
+ locale="zh-CN",
+ timezone_id="Asia/Shanghai",
+ )
+ page = ctx.new_page()
+
+ # Inject stealth script
+ stealth_path = "/usr/local/share/yao/stealth-init.js"
+ if os.path.exists(stealth_path):
+ page.add_init_script(open(stealth_path).read())
+
+ # ---- Step 1: Open Baidu ----
+ print("\n[1/7] Opening Baidu...", flush=True)
+ page.goto("https://www.baidu.com", timeout=30000)
+ page.wait_for_timeout(3000)
+ print(" Title: " + page.title(), flush=True)
+ screenshot(page, "baidu-01-homepage.png")
+
+ # ---- Step 2: Find & click search box ----
+ print("\n[2/7] Finding search box...", flush=True)
+ search_selectors = [
+ "#kw", "input[name=wd]", "input[name=word]",
+ "input.s_ipt", "input[type=text]", "input[type=search]",
+ ]
+ search_el, box = find_element(page, search_selectors)
+
+ if not box:
+ # Fallback: Baidu search box typical position
+ print(" Using fallback coordinates", flush=True)
+ box = {"x": 600, "y": 350, "width": 600, "height": 40}
+
+ cx = int(box["x"] + box["width"] / 2)
+ cy = int(box["y"] + box["height"] / 2)
+ print(" [PyAutoGUI] Clicking search box at ({}, {})".format(cx, cy), flush=True)
+ human_move(100, 100)
+ time.sleep(0.3)
+ human_click(cx, cy)
+
+ # ---- Step 3: Type search query ----
+ print("\n[3/7] Typing search query...", flush=True)
+ query = "yao app engine"
+ if search_el:
+ smart_type(search_el, query)
+ else:
+ # No element reference — type blindly with PyAutoGUI
+ for ch in query:
+ pyautogui.press(ch)
+ time.sleep(random.uniform(0.06, 0.12))
+ time.sleep(1)
+ screenshot(page, "baidu-02-typed.png")
+
+ # ---- Step 4: Submit search ----
+ print("\n[4/7] Submitting search...", flush=True)
+ pyautogui.press("enter")
+ page.wait_for_timeout(5000)
+ print(" URL: " + page.url[:100], flush=True)
+ print(" Title: " + page.title(), flush=True)
+ screenshot(page, "baidu-03-results.png")
+
+ # ---- Step 5: Find results ----
+ print("\n[5/7] Finding search results...", flush=True)
+ result_selectors = [".c-container h3 a", "h3 a", "a:has(h3)"]
+ results = find_results(page, result_selectors)
+ print(" Found {} results".format(len(results)), flush=True)
+ for i, r in enumerate(results[:5]):
+ print(" [{}] {}".format(i + 1, r["title"][:60]), flush=True)
+
+ if len(results) < 3:
+ print("\n ⚠ Not enough results. Page may show CAPTCHA.", flush=True)
+ screenshot(page, "baidu-04-no-results.png")
+ else:
+ # Baidu results page has a fixed search bar at the top (~150px).
+ # The first result (index 0) is often right under it, making
+ # PyAutoGUI click hit the search input instead of the link.
+ # So we click results starting from index 1 (second result).
+
+ # ---- Step 6: Click result #2 ----
+ print("\n[6/7] Clicking result #2: " + results[1]["title"][:60], flush=True)
+ click_result(ctx, page, results[1], "baidu-04-page1.png")
+
+ # ---- Step 7: Click result #3 ----
+ # Re-find results (page may have scrolled, coordinates changed)
+ page.evaluate("window.scrollTo(0, 0)")
+ page.wait_for_timeout(1000)
+ results2 = find_results(page, result_selectors)
+ print("\n Re-found {} results".format(len(results2)), flush=True)
+
+ if len(results2) >= 3:
+ print("\n[7/7] Clicking result #3: " + results2[2]["title"][:60], flush=True)
+ click_result(ctx, page, results2[2], "baidu-05-page2.png")
+ else:
+ print("\n[7/7] ⚠ Could not re-find results for second click", flush=True)
+
+ # Keep browser open for VNC observation
+ print("\n" + "=" * 60, flush=True)
+ print(" Demo complete! Browser stays open 30s for observation.", flush=True)
+ print(" Connect via VNC: http://localhost:6080", flush=True)
+ print("=" * 60, flush=True)
+ page.wait_for_timeout(30000)
+ browser.close()
+
+ print("Done.", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sandbox/docker/chrome/tests/demo-duckduckgo.py b/sandbox/docker/chrome/tests/demo-duckduckgo.py
new file mode 100644
index 00000000..0d42ad2d
--- /dev/null
+++ b/sandbox/docker/chrome/tests/demo-duckduckgo.py
@@ -0,0 +1,275 @@
+"""
+DuckDuckGo Search Demo — PyAutoGUI OS-level Mouse + Smart Keyboard Fallback
+
+Demonstrates anti-detection browser automation inside sandbox-claude-chrome:
+ 1. Open DuckDuckGo homepage
+ 2. Click search box with PyAutoGUI (real OS mouse event)
+ 3. Type query with PyAutoGUI keyboard (auto-fallback to Playwright if needed)
+ 4. Submit search
+ 5. Click first search result (PyAutoGUI mouse)
+ 6. Go back
+ 7. Click second search result (PyAutoGUI mouse)
+
+DuckDuckGo has no IP-based rate limiting or reCAPTCHA, making it ideal for
+demonstrating pure anti-fingerprint capabilities without IP interference.
+
+Prerequisites:
+ - Running inside sandbox-claude-chrome container
+ - DISPLAY=:99 (Xvfb virtual display)
+ - VNC optional for live observation (http://localhost:6080)
+
+Usage:
+ DISPLAY=:99 python3 demo-duckduckgo.py
+"""
+
+from playwright.sync_api import sync_playwright
+from playwright_stealth import Stealth
+import pyautogui
+import time
+import random
+import os
+
+# ---------------------------------------------------------------------------
+# PyAutoGUI config
+# ---------------------------------------------------------------------------
+pyautogui.FAILSAFE = False
+pyautogui.PAUSE = 0.1
+
+# Screenshot output directory
+SCREENSHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/workspace")
+
+
+# ---------------------------------------------------------------------------
+# Human-like helpers
+# ---------------------------------------------------------------------------
+def human_move(x, y):
+ """Move mouse with randomized speed to simulate human behavior."""
+ duration = random.uniform(0.4, 0.8)
+ pyautogui.moveTo(x, y, duration=duration)
+ time.sleep(random.uniform(0.1, 0.3))
+
+
+def human_click(x, y):
+ """Move to (x, y) then click — mimics a real user click."""
+ human_move(x, y)
+ time.sleep(random.uniform(0.05, 0.15))
+ pyautogui.click()
+ time.sleep(random.uniform(0.2, 0.5))
+
+
+def smart_type(element, text):
+ """Type text with PyAutoGUI first; fallback to Playwright if it didn't land.
+
+ On native amd64 Linux, PyAutoGUI keyboard works perfectly.
+ On ARM Mac (Rosetta 2), X11 keyboard events may not reach Chrome,
+ so we detect and automatically fallback to Playwright's type().
+ """
+ # Attempt PyAutoGUI keyboard (OS-level X11 events)
+ for ch in text:
+ pyautogui.press(ch)
+ time.sleep(random.uniform(0.05, 0.12))
+ time.sleep(0.5)
+
+ # Verify input landed
+ actual = element.input_value()
+ if actual and len(actual) >= len(text) * 0.8:
+ print(" Keyboard: PyAutoGUI (OS-level) ✓", flush=True)
+ return
+
+ # Fallback: Playwright type()
+ print(" PyAutoGUI keyboard didn't land — fallback to Playwright", flush=True)
+ element.fill("")
+ element.type(text, delay=80)
+ actual = element.input_value()
+ print(" Keyboard: Playwright fallback — '{}'".format(actual), flush=True)
+
+
+def find_element(page, selectors, min_width=50, timeout=2000):
+ """Try multiple CSS selectors, return (element, bounding_box) or (None, None)."""
+ for selector in selectors:
+ try:
+ el = page.locator(selector).first
+ box = el.bounding_box(timeout=timeout)
+ if box and box["width"] >= min_width:
+ return el, box
+ except Exception:
+ continue
+ return None, None
+
+
+def find_results(page, selectors, min_count=2, min_width=80):
+ """Find search result elements with bounding boxes."""
+ results = []
+ for selector in selectors:
+ elements = page.locator(selector).all()
+ for el in elements[:10]:
+ try:
+ box = el.bounding_box(timeout=1000)
+ title = el.text_content().strip()
+ if (box and box["width"] >= min_width and box["y"] > 0
+ and title and len(title) > 5):
+ if not any(r["title"] == title for r in results):
+ results.append({"box": box, "title": title})
+ except Exception:
+ pass
+ if len(results) >= min_count:
+ break
+ return results
+
+
+def screenshot(page, name):
+ """Save screenshot to SCREENSHOT_DIR."""
+ path = os.path.join(SCREENSHOT_DIR, name)
+ page.screenshot(path=path)
+ print(" Screenshot: " + path, flush=True)
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+def main():
+ print("=" * 60, flush=True)
+ print(" DuckDuckGo Search — PyAutoGUI OS-Level Demo", flush=True)
+ print("=" * 60, flush=True)
+
+ with Stealth().use_sync(sync_playwright()) as p:
+ browser = p.chromium.launch(
+ channel="chrome",
+ headless=False,
+ args=[
+ "--no-sandbox",
+ "--disable-blink-features=AutomationControlled",
+ "--disable-dev-shm-usage",
+ "--window-size=1920,1080",
+ "--window-position=0,0",
+ ],
+ )
+ ctx = browser.new_context(
+ viewport={"width": 1920, "height": 1080},
+ locale="en-US",
+ timezone_id="America/New_York",
+ )
+ page = ctx.new_page()
+
+ # Inject stealth script
+ stealth_path = "/usr/local/share/yao/stealth-init.js"
+ if os.path.exists(stealth_path):
+ page.add_init_script(open(stealth_path).read())
+
+ # ---- Step 1: Open DuckDuckGo ----
+ print("\n[1/7] Opening DuckDuckGo...", flush=True)
+ page.goto("https://duckduckgo.com", timeout=30000)
+ page.wait_for_timeout(3000)
+ print(" Title: " + page.title(), flush=True)
+ screenshot(page, "ddg-01-homepage.png")
+
+ # ---- Step 2: Find & click search box ----
+ print("\n[2/7] Finding search box...", flush=True)
+ search_selectors = [
+ "input[name=q]", "#searchbox_input",
+ "input[type=text]", "input[placeholder*='Search']",
+ ]
+ search_el, box = find_element(page, search_selectors, min_width=100)
+
+ if not box:
+ print(" ⚠ Could not find search box!", flush=True)
+ screenshot(page, "ddg-02-no-searchbox.png")
+ browser.close()
+ return
+
+ cx = int(box["x"] + box["width"] / 2)
+ cy = int(box["y"] + box["height"] / 2)
+ print(" [PyAutoGUI] Clicking search box at ({}, {})".format(cx, cy), flush=True)
+ human_move(200, 200)
+ time.sleep(0.3)
+ human_click(cx, cy)
+
+ # ---- Step 3: Type search query ----
+ print("\n[3/7] Typing search query...", flush=True)
+ query = "yao app engine github"
+ smart_type(search_el, query)
+ time.sleep(0.5)
+ screenshot(page, "ddg-02-typed.png")
+
+ # ---- Step 4: Submit search ----
+ print("\n[4/7] Submitting search...", flush=True)
+ # Use Playwright Enter on the element (reliable cross-platform)
+ search_el.press("Enter")
+ page.wait_for_timeout(6000)
+ print(" URL: " + page.url[:120], flush=True)
+ print(" Title: " + page.title()[:80], flush=True)
+ screenshot(page, "ddg-03-results.png")
+
+ # Check if we actually reached results page
+ on_results = (
+ "q=" in page.url
+ or "/search" in page.url
+ or page.title() != "DuckDuckGo - Protection. Privacy. Peace of mind."
+ )
+ if not on_results:
+ print(" ⚠ Still on homepage — search may not have submitted", flush=True)
+ screenshot(page, "ddg-03-still-homepage.png")
+ browser.close()
+ return
+
+ # ---- Step 5: Find results ----
+ print("\n[5/7] Finding search results...", flush=True)
+ result_selectors = [
+ "article h2 a", "a[data-testid='result-title-a']",
+ "h2 a[href]", "ol li h2 a", "h2 a",
+ ]
+ results = find_results(page, result_selectors, min_count=3)
+ print(" Found {} results".format(len(results)), flush=True)
+ for i, r in enumerate(results[:5]):
+ print(" [{}] {}".format(i + 1, r["title"][:70]), flush=True)
+
+ if len(results) < 2:
+ print("\n ⚠ Not enough results to click.", flush=True)
+ screenshot(page, "ddg-04-no-results.png")
+ else:
+ # ---- Step 6: Click first result ----
+ r1 = results[0]
+ rx = int(r1["box"]["x"] + r1["box"]["width"] / 2)
+ ry = int(r1["box"]["y"] + r1["box"]["height"] / 2)
+ print("\n[6/7] [PyAutoGUI] Clicking result #1 at ({},{})".format(rx, ry), flush=True)
+ print(" " + r1["title"][:70], flush=True)
+ pyautogui.scroll(-1)
+ time.sleep(0.3)
+ human_click(rx, ry)
+ page.wait_for_timeout(6000)
+ print(" Landed: {} | {}".format(page.title()[:50], page.url[:80]), flush=True)
+ screenshot(page, "ddg-04-page1.png")
+
+ # Go back
+ print(" [PyAutoGUI] Going back (Alt+Left)...", flush=True)
+ pyautogui.hotkey("alt", "Left")
+ page.wait_for_timeout(4000)
+
+ # ---- Step 7: Click second result ----
+ results2 = find_results(page, result_selectors, min_count=3)
+ if len(results2) >= 2:
+ r2 = results2[1]
+ rx2 = int(r2["box"]["x"] + r2["box"]["width"] / 2)
+ ry2 = int(r2["box"]["y"] + r2["box"]["height"] / 2)
+ print("\n[7/7] [PyAutoGUI] Clicking result #2 at ({},{})".format(rx2, ry2), flush=True)
+ print(" " + r2["title"][:70], flush=True)
+ human_click(rx2, ry2)
+ page.wait_for_timeout(6000)
+ print(" Landed: {} | {}".format(page.title()[:50], page.url[:80]), flush=True)
+ screenshot(page, "ddg-05-page2.png")
+ else:
+ print("\n[7/7] ⚠ Could not re-find results for second click", flush=True)
+
+ # Keep browser open for VNC observation
+ print("\n" + "=" * 60, flush=True)
+ print(" Demo complete! Browser stays open 30s for observation.", flush=True)
+ print(" Connect via VNC: http://localhost:6080", flush=True)
+ print("=" * 60, flush=True)
+ page.wait_for_timeout(30000)
+ browser.close()
+
+ print("Done.", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sandbox/docker/chrome/tests/demo-llm-vision.py b/sandbox/docker/chrome/tests/demo-llm-vision.py
new file mode 100644
index 00000000..9bd7c01e
--- /dev/null
+++ b/sandbox/docker/chrome/tests/demo-llm-vision.py
@@ -0,0 +1,860 @@
+"""
+LLM + DOM + CDP Browser Automation Demo
+
+Architecture (each layer does what it's best at):
+ - Playwright: Opens pages, extracts HTML, keyboard input
+ - LLM: Reads HTML structure, returns CSS selectors for target elements
+ - DOM: Uses LLM's selectors to get precise bounding boxes
+ - CDP: Chrome DevTools Protocol mouse events (isTrusted=true, anti-detection)
+ - PyAutoGUI: OS-level mouse fallback (when CDP fails)
+
+Click strategy (layered, most reliable first):
+ 1. CDP Input.dispatchMouseEvent — coordinates match bounding_box() exactly,
+ generates isTrusted=true events, nearly indistinguishable from real user.
+ 2. PyAutoGUI OS-level mouse — true hardware events, but coordinates may
+ drift due to window chrome offset.
+ 3. Playwright .click() — last resort, may be detected by anti-bot.
+
+No hardcoded selectors — LLM figures out the page structure dynamically.
+Works with any cheap text LLM (no vision needed).
+
+Environment variables (required):
+ LLM_API_KEY — API key
+ LLM_API_BASE — OpenAI-compatible endpoint URL
+ LLM_MODEL — Model name/ID
+
+Usage:
+ export LLM_API_KEY="your-key"
+ export LLM_API_BASE="https://api.openai.com/v1/"
+ export LLM_MODEL="gpt-4o-mini"
+ DISPLAY=:99 python3 demo-llm-vision.py
+
+Example:
+ DISPLAY=:99 python3 demo-llm-vision.py https://www.google.com "Yao App Engine"
+ DISPLAY=:99 python3 demo-llm-vision.py https://www.baidu.com "Yao App Engine"
+"""
+
+from playwright.sync_api import sync_playwright
+from playwright_stealth import Stealth
+import pyautogui
+import time
+import random
+import json
+import os
+import re
+import urllib.request
+import urllib.error
+import sys
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+# ---------------------------------------------------------------------------
+# Config
+# ---------------------------------------------------------------------------
+pyautogui.FAILSAFE = False
+pyautogui.PAUSE = 0.1
+SCREENSHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/workspace")
+
+LLM_API_KEY = os.environ.get("LLM_API_KEY", "").strip().strip('"\'')
+LLM_API_BASE = os.environ.get("LLM_API_BASE", "").strip().strip('"\'')
+LLM_MODEL = os.environ.get("LLM_MODEL", "").strip().strip('"\'')
+if LLM_API_BASE and not LLM_API_BASE.endswith("/"):
+ LLM_API_BASE += "/"
+
+# Command-line arguments:
+SEARCH_URL = sys.argv[1] if len(sys.argv) > 1 else "https://www.google.com"
+SEARCH_QUERY = sys.argv[2] if len(sys.argv) > 2 else "Yao App Engine"
+
+
+# ---------------------------------------------------------------------------
+# LLM API
+# ---------------------------------------------------------------------------
+def ask_llm(prompt, timeout=60):
+ """Send text prompt to LLM, return text response."""
+ url = LLM_API_BASE + "chat/completions"
+ payload = {
+ "model": LLM_MODEL,
+ "messages": [{"role": "user", "content": prompt}],
+ "max_tokens": 2048,
+ "temperature": 0.1,
+ }
+ headers = {"Content-Type": "application/json", "Authorization": "Bearer " + LLM_API_KEY}
+ data = json.dumps(payload).encode("utf-8")
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ result = json.loads(resp.read().decode("utf-8"))
+ return result["choices"][0]["message"]["content"]
+ except urllib.error.HTTPError as e:
+ body = e.read().decode("utf-8")[:500] if e.fp else ""
+ print(" LLM HTTP {}: {}".format(e.code, body), flush=True)
+ return None
+ except Exception as e:
+ print(" LLM error: {}".format(e), flush=True)
+ return None
+
+
+def parse_llm_json(text):
+ """Extract JSON from LLM response (handles markdown fences, thinking tags)."""
+ if not text:
+ return None
+ cleaned = re.sub(r'[\s\S]*?', '', text).strip()
+ cleaned = re.sub(r'^```\w*\n?', '', cleaned)
+ cleaned = re.sub(r'\n?```$', '', cleaned)
+ cleaned = cleaned.strip()
+ try:
+ return json.loads(cleaned)
+ except json.JSONDecodeError:
+ for m in re.finditer(r'(\{[\s\S]*?\}|\[[\s\S]*?\])', cleaned):
+ try:
+ return json.loads(m.group())
+ except json.JSONDecodeError:
+ continue
+ return None
+
+
+def ask_llm_race(prompts, timeout=60, validator=None):
+ """Send multiple prompts to LLM concurrently, return first valid result.
+ Each prompt is sent in a separate thread. As soon as one returns a valid
+ JSON result (passing optional validator), return immediately without
+ waiting for the remaining threads.
+
+ Args:
+ prompts: list of (label, prompt_text) tuples
+ timeout: per-request timeout in seconds
+ validator: optional fn(parsed_json) -> bool, extra check on result
+
+ Returns:
+ (label, parsed_json) of the first valid result, or (None, None)
+ """
+ if not prompts:
+ return None, None
+
+ # Single prompt — no need for concurrency
+ if len(prompts) == 1:
+ label, prompt_text = prompts[0]
+ resp = ask_llm(prompt_text, timeout=timeout)
+ parsed = parse_llm_json(resp)
+ if parsed and (validator is None or validator(parsed)):
+ return label, parsed
+ return None, None
+
+ print(" [Race] Sending {} concurrent LLM requests...".format(len(prompts)), flush=True)
+
+ # Don't use `with` — it calls shutdown(wait=True) which blocks until ALL
+ # threads finish, even after we found a winner. Instead, manage manually
+ # and call shutdown(wait=False) to return immediately.
+ pool = ThreadPoolExecutor(max_workers=len(prompts))
+ future_map = {}
+ for label, prompt_text in prompts:
+ f = pool.submit(ask_llm, prompt_text, timeout)
+ future_map[f] = label
+
+ try:
+ for f in as_completed(future_map):
+ label = future_map[f]
+ try:
+ resp = f.result()
+ parsed = parse_llm_json(resp)
+ if parsed and (validator is None or validator(parsed)):
+ print(" [Race] Winner: '{}' → {}".format(
+ label, json.dumps(parsed, ensure_ascii=False)[:120]), flush=True)
+ # Cancel pending futures (won't stop running ones, but prevents queued)
+ for other in future_map:
+ if other is not f:
+ other.cancel()
+ return label, parsed
+ else:
+ print(" [Race] '{}' returned invalid result, waiting...".format(label), flush=True)
+ except Exception as e:
+ print(" [Race] '{}' failed: {}".format(label, e), flush=True)
+ finally:
+ # shutdown(wait=False) — let daemon threads die on their own,
+ # don't block the main thread waiting for slow LLM responses.
+ pool.shutdown(wait=False, cancel_futures=True)
+
+ print(" [Race] All requests failed", flush=True)
+ return None, None
+
+
+# ---------------------------------------------------------------------------
+# DOM extraction — compact page summary for LLM
+# ---------------------------------------------------------------------------
+def extract_page_dom(page):
+ """Extract a compact text summary of all interactive elements on the page.
+ Includes inputs, buttons, and visible links — generic, no hardcoded selectors.
+ LLM reads this to decide what to interact with."""
+ return page.evaluate("""() => {
+ const lines = [];
+ lines.push('URL: ' + location.href);
+ lines.push('Title: ' + document.title);
+ lines.push('');
+
+ // Helper: describe element visibility
+ function vis(el) {
+ const r = el.getBoundingClientRect();
+ return (r.width > 5 && r.height > 5)
+ ? '[VISIBLE ' + Math.round(r.width) + 'x' + Math.round(r.height) + ']'
+ : '[HIDDEN]';
+ }
+
+ // Helper: build a minimal CSS selector for an element
+ function sel(el) {
+ const tag = el.tagName.toLowerCase();
+ if (el.id) return tag + '#' + el.id;
+ if (el.className) {
+ const cls = el.className.toString().trim().split(/\\s+/).slice(0, 2).join('.');
+ if (cls) return tag + '.' + cls;
+ }
+ return tag;
+ }
+
+ // 1. Inputs, textareas, buttons
+ lines.push('=== Inputs & Buttons ===');
+ document.querySelectorAll('input, textarea, button, [role="textbox"], [contenteditable="true"]').forEach(el => {
+ const tag = el.tagName.toLowerCase();
+ const parts = [sel(el)];
+ if (el.type && el.type !== 'text') parts.push('type="' + el.type + '"');
+ if (el.name) parts.push('name="' + el.name + '"');
+ if (el.placeholder) parts.push('placeholder="' + el.placeholder.substring(0, 40) + '"');
+ if (el.value) parts.push('value="' + el.value.substring(0, 30) + '"');
+ const text = (el.innerText || '').trim().substring(0, 30);
+ if (text && tag === 'button') parts.push('text="' + text + '"');
+ if (el.getAttribute('aria-label')) parts.push('aria-label="' + el.getAttribute('aria-label').substring(0, 30) + '"');
+ parts.push(vis(el));
+ lines.push(' ' + parts.join(' '));
+ });
+
+ // 2. Visible links
+ lines.push('');
+ lines.push('=== Links ===');
+ const seen = new Set();
+ let count = 0;
+ document.querySelectorAll('a[href]').forEach(a => {
+ if (count >= 30) return;
+ const href = a.href || '';
+ if (!href || href.startsWith('javascript:')) return;
+
+ const rect = a.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ if (rect.top < 30) return;
+
+ const text = (a.innerText || '').trim().replace(/\\s+/g, ' ').substring(0, 80);
+ if (!text || text.length < 2 || seen.has(text)) return;
+ seen.add(text);
+
+ const parent = a.parentElement;
+ let ctx = parent ? sel(parent) + ' > ' : '';
+ const heading = a.querySelector('h1,h2,h3,h4');
+ const htag = heading ? ' [has <' + heading.tagName.toLowerCase() + '>]' : '';
+
+ lines.push(' ' + ctx + sel(a) + htag + ' ' + vis(a) + ' → "' + text + '"');
+ count++;
+ });
+
+ return lines.join('\\n');
+ }""")
+
+
+
+# ---------------------------------------------------------------------------
+# CDP click — Chrome DevTools Protocol mouse events (isTrusted=true)
+# ---------------------------------------------------------------------------
+_cdp_session = None
+_cdp_page_id = None
+
+
+def get_cdp_session(page):
+ """Get or create a CDP session for the given page.
+ Recreates session if page changed (e.g. after tab close/navigation)."""
+ global _cdp_session, _cdp_page_id
+ page_id = id(page)
+ if _cdp_session is None or _cdp_page_id != page_id:
+ try:
+ if _cdp_session:
+ _cdp_session.detach()
+ except Exception:
+ pass
+ _cdp_session = page.context.new_cdp_session(page)
+ _cdp_page_id = page_id
+ return _cdp_session
+
+
+def cdp_click(page, x, y, ctrl=False):
+ """Click at (x, y) via CDP Input.dispatchMouseEvent.
+ Coordinates are in viewport space (same as bounding_box()).
+ Generates isTrusted=true events — nearly indistinguishable from real user.
+ When ctrl=True, holds Ctrl modifier to force new tab (like Ctrl+Click)."""
+ cdp = get_cdp_session(page)
+
+ # Simulate human-like: small random offset (±2px)
+ x += random.uniform(-2, 2)
+ y += random.uniform(-2, 2)
+
+ modifiers = 2 if ctrl else 0 # 2 = Ctrl modifier in CDP
+
+ # mouseMoved — simulate cursor arriving
+ cdp.send("Input.dispatchMouseEvent", {
+ "type": "mouseMoved",
+ "x": x, "y": y,
+ "button": "none",
+ "modifiers": modifiers,
+ "pointerType": "mouse",
+ })
+ time.sleep(random.uniform(0.05, 0.15))
+
+ # mousePressed
+ cdp.send("Input.dispatchMouseEvent", {
+ "type": "mousePressed",
+ "x": x, "y": y,
+ "button": "left",
+ "clickCount": 1,
+ "modifiers": modifiers,
+ "pointerType": "mouse",
+ })
+ time.sleep(random.uniform(0.03, 0.08))
+
+ # mouseReleased
+ cdp.send("Input.dispatchMouseEvent", {
+ "type": "mouseReleased",
+ "x": x, "y": y,
+ "button": "left",
+ "clickCount": 1,
+ "modifiers": modifiers,
+ "pointerType": "mouse",
+ })
+ time.sleep(random.uniform(0.1, 0.3))
+
+
+def cdp_move(page, x, y, steps=10):
+ """Simulate human-like mouse movement via CDP (curved path)."""
+ cdp = get_cdp_session(page)
+ # Start from a random nearby position
+ sx = x + random.uniform(-200, 200)
+ sy = y + random.uniform(-100, 100)
+ for i in range(steps + 1):
+ t = i / steps
+ # Ease-in-out curve
+ t = t * t * (3 - 2 * t)
+ mx = sx + (x - sx) * t + random.uniform(-1, 1)
+ my = sy + (y - sy) * t + random.uniform(-1, 1)
+ cdp.send("Input.dispatchMouseEvent", {
+ "type": "mouseMoved",
+ "x": mx, "y": my,
+ "button": "none",
+ "pointerType": "mouse",
+ })
+ time.sleep(random.uniform(0.01, 0.03))
+
+
+def smart_click(page, x, y, label=""):
+ """Click using CDP (primary) with PyAutoGUI fallback.
+ Returns the method used: 'cdp', 'pyautogui', or None on failure."""
+ tag = "[CDP]" if label else "[CDP]"
+ try:
+ cdp_move(page, x, y)
+ cdp_click(page, x, y)
+ print(" {} Click at ({},{}){}".format(
+ tag, int(x), int(y),
+ " '{}'".format(label[:40]) if label else ""), flush=True)
+ return "cdp"
+ except Exception as e:
+ print(" {} Failed: {} → PyAutoGUI fallback".format(tag, e), flush=True)
+ try:
+ pyautogui.moveTo(x, y, duration=random.uniform(0.4, 0.8))
+ time.sleep(random.uniform(0.05, 0.15))
+ pyautogui.click()
+ time.sleep(random.uniform(0.2, 0.5))
+ print(" [PyAutoGUI] Click at ({},{})".format(int(x), int(y)), flush=True)
+ return "pyautogui"
+ except Exception as e2:
+ print(" [PyAutoGUI] Also failed: {}".format(e2), flush=True)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Interaction helpers
+# ---------------------------------------------------------------------------
+def box_center(box):
+ return int(box["x"] + box["width"] / 2), int(box["y"] + box["height"] / 2)
+
+
+def take_screenshot(page, name):
+ path = os.path.join(SCREENSHOT_DIR, name)
+ page.screenshot(path=path)
+ print(" Screenshot: " + path, flush=True)
+
+
+def locate_element(page, selector):
+ """Use a CSS selector to find element, return (element, bounding_box) or (None, None)."""
+ try:
+ loc = page.locator(selector)
+ count = loc.count()
+ print(" [locate] '{}' matched {} elements".format(selector, count), flush=True)
+ if count == 0:
+ return None, None
+ el = loc.first
+ # Try to make it visible first
+ try:
+ el.scroll_into_view_if_needed(timeout=2000)
+ except Exception:
+ pass
+ box = el.bounding_box(timeout=5000)
+ if box:
+ print(" [locate] box: x={} y={} w={} h={}".format(
+ int(box["x"]), int(box["y"]), int(box["width"]), int(box["height"])), flush=True)
+ if box["width"] > 5:
+ return el, box
+ else:
+ print(" [locate] bounding_box returned None (element hidden?)", flush=True)
+ except Exception as e:
+ print(" [locate] error: {}".format(e), flush=True)
+ return None, None
+
+
+def locate_elements(page, selector, min_y=0):
+ """Find all visible elements matching selector, return list of (element, box, text)."""
+ results = []
+ try:
+ els = page.locator(selector).all()
+ for el in els:
+ try:
+ box = el.bounding_box(timeout=500)
+ if box and box["width"] > 30 and box["y"] > min_y:
+ text = el.inner_text(timeout=500)[:80]
+ results.append((el, box, text))
+ except Exception:
+ continue
+ except Exception:
+ pass
+ return results
+
+
+def click_new_tab(ctx, page, el, box, label):
+ """Ctrl+Click an element via CDP to force open in new tab.
+ Keeps the search results page intact. Waits for new tab, screenshots, closes it.
+ Fallback chain: CDP Ctrl+Click → Playwright Ctrl+Click → JS window.open"""
+ cx, cy = box_center(box)
+
+ # Scroll into view if needed
+ if cy < 100 or cy > 1000:
+ try:
+ el.scroll_into_view_if_needed(timeout=2000)
+ time.sleep(0.5)
+ box = el.bounding_box(timeout=1000)
+ if box:
+ cx, cy = box_center(box)
+ except Exception:
+ pass
+
+ pages_before = len(ctx.pages)
+
+ # --- Attempt 1: CDP Ctrl+Click (isTrusted=true, new tab) ---
+ try:
+ cdp_move(page, cx, cy)
+ cdp_click(page, cx, cy, ctrl=True)
+ print(" [CDP Ctrl+Click] '{}' at ({},{})".format(label[:40], int(cx), int(cy)), flush=True)
+ except Exception as e:
+ print(" [CDP Ctrl+Click] Failed: {}".format(e), flush=True)
+
+ # Wait for new tab
+ for _ in range(12):
+ page.wait_for_timeout(500)
+ if len(ctx.pages) > pages_before:
+ break
+
+ if len(ctx.pages) > pages_before:
+ target = ctx.pages[-1]
+ target.wait_for_timeout(6000)
+ print(" ✓ [New Tab] {} | {}".format(target.title()[:50], target.url[:80]), flush=True)
+ take_screenshot(target, "llm-detail.png")
+ target.close()
+ page.wait_for_timeout(500)
+ # Bring focus back to search results page
+ page.bring_to_front()
+ return True
+
+ # --- Attempt 2: Playwright modifier click ---
+ print(" CDP Ctrl+Click no new tab → Playwright modifier click", flush=True)
+ try:
+ el.click(modifiers=["Control"], timeout=3000)
+ page.wait_for_timeout(3000)
+ if len(ctx.pages) > pages_before:
+ target = ctx.pages[-1]
+ target.wait_for_timeout(6000)
+ print(" ✓ [New Tab] {} | {}".format(target.title()[:50], target.url[:80]), flush=True)
+ take_screenshot(target, "llm-detail.png")
+ target.close()
+ page.wait_for_timeout(500)
+ page.bring_to_front()
+ return True
+ except Exception:
+ pass
+
+ # --- Attempt 3: JS window.open with href ---
+ print(" Modifier click failed → JS window.open fallback", flush=True)
+ try:
+ href = el.get_attribute("href", timeout=2000)
+ if href:
+ new_page = ctx.new_page()
+ new_page.goto(href, timeout=15000)
+ new_page.wait_for_timeout(5000)
+ print(" ✓ [JS Tab] {} | {}".format(new_page.title()[:50], new_page.url[:80]), flush=True)
+ take_screenshot(new_page, "llm-detail.png")
+ new_page.close()
+ page.wait_for_timeout(500)
+ page.bring_to_front()
+ return True
+ except Exception as e:
+ print(" [JS Tab] Failed: {}".format(e), flush=True)
+
+ print(" ✗ All methods failed to open new tab", flush=True)
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+def main():
+ print("=" * 60, flush=True)
+ print(" LLM + DOM + CDP Browser Automation", flush=True)
+ print("=" * 60, flush=True)
+ print(" URL: " + SEARCH_URL, flush=True)
+ print(" Query: " + SEARCH_QUERY, flush=True)
+ print(" Model: " + LLM_MODEL, flush=True)
+ print(" Endpoint: " + LLM_API_BASE[:60], flush=True)
+ print(" Key: " + (LLM_API_KEY[:8] + "..." if LLM_API_KEY else "NOT SET"), flush=True)
+ print("", flush=True)
+ print(" Flow: LLM reads HTML → returns CSS selectors →", flush=True)
+ print(" DOM locates elements → CDP clicks (isTrusted)", flush=True)
+
+ if not all([LLM_API_KEY, LLM_API_BASE, LLM_MODEL]):
+ print("\n⚠ Missing LLM config! Set: LLM_API_KEY, LLM_API_BASE, LLM_MODEL", flush=True)
+ return
+
+ with Stealth().use_sync(sync_playwright()) as p:
+ browser = p.chromium.launch(
+ channel="chrome", headless=False,
+ args=["--no-sandbox", "--disable-blink-features=AutomationControlled",
+ "--disable-dev-shm-usage", "--window-size=1920,1080", "--window-position=0,0"])
+ ctx = browser.new_context(
+ viewport={"width": 1920, "height": 1080},
+ locale="zh-CN", timezone_id="Asia/Shanghai")
+ page = ctx.new_page()
+
+ stealth_path = "/usr/local/share/yao/stealth-init.js"
+ if os.path.exists(stealth_path):
+ page.add_init_script(open(stealth_path).read())
+
+ # ============================================================
+ # Phase 1: Open search engine
+ # ============================================================
+ print("\n[Phase 1] Opening {}...".format(SEARCH_URL), flush=True)
+ page.goto(SEARCH_URL, timeout=30000)
+ page.wait_for_timeout(3000)
+ print(" Title: " + page.title(), flush=True)
+ take_screenshot(page, "llm-01-homepage.png")
+
+ # ============================================================
+ # Phase 2: LLM analyzes homepage HTML → gives selectors
+ # ============================================================
+ print("\n[Phase 2] [LLM] Analyzing homepage structure...", flush=True)
+ elements = extract_page_dom(page)
+ print(elements[:500], flush=True)
+ if len(elements) > 500:
+ print(" ... ({} chars total)".format(len(elements)), flush=True)
+
+ hp_prompt_tpl = """Below is the DOM structure of a search engine homepage.
+Each element is marked [VISIBLE WxH] or [HIDDEN].
+
+I want to:
+1. Type a search query into the search input box
+2. Click the search submit button
+
+IMPORTANT: Only pick elements marked [VISIBLE]. Ignore [HIDDEN] elements.
+Give me CSS selectors for both elements.
+
+{}
+
+Reply ONLY with JSON (no other text):
+{{"input_selector": "", "button_selector": ""}}"""
+
+ # Split homepage DOM into chunks for concurrent LLM calls
+ hp_lines = elements.split('\n')
+ hp_header = []
+ hp_body = []
+ for line in hp_lines:
+ if line.startswith("URL:") or line.startswith("Title:") or line == "":
+ hp_header.append(line)
+ else:
+ hp_body.append(line)
+ hp_hdr = '\n'.join(hp_header[:3])
+
+ hp_chunks = []
+ cur_chunk = []
+ cur_len = 0
+ for line in hp_body:
+ cur_chunk.append(line)
+ cur_len += len(line) + 1
+ if cur_len >= 2000:
+ hp_chunks.append('\n'.join(cur_chunk))
+ cur_chunk = []
+ cur_len = 0
+ if cur_chunk:
+ hp_chunks.append('\n'.join(cur_chunk))
+
+ hp_prompts = []
+ if len(hp_chunks) > 1:
+ for i, chunk in enumerate(hp_chunks):
+ hp_prompts.append(("chunk-{}".format(i + 1), hp_prompt_tpl.format(hp_hdr + '\n' + chunk)))
+ hp_prompts.append(("full", hp_prompt_tpl.format(elements)))
+
+ print(" [Phase 2] {} concurrent LLM requests".format(len(hp_prompts)), flush=True)
+
+ def _valid_homepage(parsed):
+ return (isinstance(parsed, dict)
+ and bool(parsed.get("input_selector", "").strip())
+ and bool(parsed.get("button_selector", "").strip()))
+
+ label, selectors = ask_llm_race(hp_prompts, timeout=180, validator=_valid_homepage)
+
+ if not selectors or not isinstance(selectors, dict):
+ print(" ✗ LLM failed to return selectors", flush=True)
+ browser.close()
+ return
+
+ input_sel = selectors.get("input_selector", "")
+ button_sel = selectors.get("button_selector", "")
+ print(" → Input: {}".format(input_sel), flush=True)
+ print(" → Button: {}".format(button_sel), flush=True)
+
+ # ============================================================
+ # Phase 3: DOM locates elements → CDP clicks + Playwright types
+ # ============================================================
+ print("\n[Phase 3] [DOM] Locating input: '{}'".format(input_sel), flush=True)
+ input_el, input_box = locate_element(page, input_sel)
+ if not input_el:
+ print(" ✗ Selector '{}' didn't match! Aborting.".format(input_sel), flush=True)
+ browser.close()
+ return
+
+ ix, iy = box_center(input_box)
+ print(" ✓ Input at ({},{}) size={}x{}".format(
+ ix, iy, int(input_box["width"]), int(input_box["height"])), flush=True)
+
+ smart_click(page, ix, iy, "search input")
+ time.sleep(0.3)
+
+ print(" [Playwright] Type '{}'".format(SEARCH_QUERY), flush=True)
+ input_el.type(SEARCH_QUERY, delay=80)
+ time.sleep(0.5)
+ take_screenshot(page, "llm-02-typed.png")
+
+ # ============================================================
+ # Phase 4: DOM locates button → CDP clicks
+ # ============================================================
+ print("\n[Phase 4] [DOM] Locating button: '{}'".format(button_sel), flush=True)
+ btn_el, btn_box = locate_element(page, button_sel)
+ if btn_el and btn_box:
+ bx, by = box_center(btn_box)
+ print(" ✓ Button at ({},{})".format(bx, by), flush=True)
+ smart_click(page, bx, by, "search button")
+ else:
+ print(" Button not found → Enter key via CDP", flush=True)
+ smart_click(page, ix, iy, "input focus")
+ time.sleep(0.2)
+ try:
+ cdp = get_cdp_session(page)
+ cdp.send("Input.dispatchKeyEvent", {
+ "type": "keyDown", "key": "Enter", "code": "Enter",
+ "windowsVirtualKeyCode": 13, "nativeVirtualKeyCode": 13,
+ })
+ cdp.send("Input.dispatchKeyEvent", {
+ "type": "keyUp", "key": "Enter", "code": "Enter",
+ "windowsVirtualKeyCode": 13, "nativeVirtualKeyCode": 13,
+ })
+ except Exception:
+ pyautogui.press("enter")
+
+ page.wait_for_timeout(5000)
+ results_url = page.url
+ print(" URL: " + results_url[:100], flush=True)
+ print(" Title: " + page.title()[:60], flush=True)
+ take_screenshot(page, "llm-03-results.png")
+
+ # If URL unchanged, fallback: Enter key → Playwright click → form submit
+ homepage = SEARCH_URL.rstrip("/")
+ if results_url.rstrip("/") == homepage:
+ print(" URL unchanged — trying CDP Enter key fallback...", flush=True)
+ smart_click(page, ix, iy, "input refocus")
+ time.sleep(0.2)
+ try:
+ cdp = get_cdp_session(page)
+ cdp.send("Input.dispatchKeyEvent", {
+ "type": "keyDown", "key": "Enter", "code": "Enter",
+ "windowsVirtualKeyCode": 13, "nativeVirtualKeyCode": 13,
+ })
+ cdp.send("Input.dispatchKeyEvent", {
+ "type": "keyUp", "key": "Enter", "code": "Enter",
+ "windowsVirtualKeyCode": 13, "nativeVirtualKeyCode": 13,
+ })
+ except Exception:
+ pyautogui.press("enter")
+ page.wait_for_timeout(5000)
+ results_url = page.url
+
+ if results_url.rstrip("/") == homepage:
+ print(" Still unchanged — trying Playwright click fallback...", flush=True)
+ try:
+ if btn_el:
+ btn_el.click(timeout=3000)
+ else:
+ input_el.press("Enter")
+ page.wait_for_timeout(5000)
+ results_url = page.url
+ except Exception:
+ pass
+
+ if results_url.rstrip("/") == homepage:
+ print(" Still unchanged — trying form submit fallback...", flush=True)
+ try:
+ page.evaluate("document.querySelector('form')?.submit()")
+ page.wait_for_timeout(5000)
+ results_url = page.url
+ except Exception:
+ pass
+
+ print(" Final URL: " + results_url[:100], flush=True)
+ take_screenshot(page, "llm-03-results.png")
+
+ if results_url.rstrip("/") == homepage:
+ print(" ✗ All submit methods failed", flush=True)
+ browser.close()
+ return
+
+ # ============================================================
+ # Phase 5: LLM analyzes results page → gives link selector
+ # Split DOM into chunks, race concurrent LLM calls
+ # ============================================================
+ print("\n[Phase 5] [LLM] Analyzing search results page...", flush=True)
+
+ results_dom = extract_page_dom(page)
+ print(results_dom[:500], flush=True)
+ if len(results_dom) > 500:
+ print(" ... ({} chars total)".format(len(results_dom)), flush=True)
+
+ # Split DOM into chunks for concurrent LLM calls
+ dom_lines = results_dom.split('\n')
+ link_prompt_tpl = """Below is part of the DOM from a search results page.
+Each line shows: parent > link_selector [has if any] → "link text"
+
+I need a CSS selector that matches the organic search result title links.
+NOT ads, NOT navigation, NOT pagination — only the main result links.
+
+{}
+
+Reply ONLY JSON: {{"link_selector": ""}}"""
+
+ # Build chunks: split at ~2000 char boundaries, always include URL/Title header
+ header_lines = []
+ body_lines = []
+ for line in dom_lines:
+ if line.startswith("URL:") or line.startswith("Title:") or line == "":
+ header_lines.append(line)
+ else:
+ body_lines.append(line)
+ header = '\n'.join(header_lines[:3]) # URL + Title + blank
+
+ chunks = []
+ current_chunk = []
+ current_len = 0
+ chunk_limit = 2000
+ for line in body_lines:
+ current_chunk.append(line)
+ current_len += len(line) + 1
+ if current_len >= chunk_limit:
+ chunks.append('\n'.join(current_chunk))
+ current_chunk = []
+ current_len = 0
+ if current_chunk:
+ chunks.append('\n'.join(current_chunk))
+
+ # Also send the full DOM as one prompt (in case chunks miss context)
+ prompts = []
+ if len(chunks) > 1:
+ for i, chunk in enumerate(chunks):
+ chunk_dom = header + '\n' + chunk
+ prompts.append(("chunk-{}".format(i + 1), link_prompt_tpl.format(chunk_dom)))
+ # Always include the full DOM as the last prompt
+ prompts.append(("full", link_prompt_tpl.format(results_dom)))
+
+ print(" [Phase 5] {} concurrent LLM requests ({} chunks + full)".format(
+ len(prompts), len(chunks) if len(chunks) > 1 else 0), flush=True)
+
+ def _valid_link_selector(parsed):
+ return isinstance(parsed, dict) and bool(parsed.get("link_selector", "").strip())
+
+ label, link_info = ask_llm_race(prompts, timeout=180, validator=_valid_link_selector)
+
+ link_sel = ""
+ link_results = []
+ if link_info and isinstance(link_info, dict):
+ link_sel = link_info.get("link_selector", "")
+ print(" → Selector: '{}' (from {})".format(link_sel, label), flush=True)
+ if link_sel:
+ link_results = locate_elements(page, link_sel, min_y=100)
+
+ print(" Found {} clickable links".format(len(link_results)), flush=True)
+ for i, (el, box, text) in enumerate(link_results[:5]):
+ cx, cy = box_center(box)
+ print(" [{}] ({},{}) '{}'".format(i, cx, cy, text[:50]), flush=True)
+
+ if len(link_results) < 1:
+ print(" ✗ No links found", flush=True)
+ take_screenshot(page, "llm-04-no-links.png")
+ browser.close()
+ return
+
+ # ============================================================
+ # Phase 7+: Ctrl+Click results (open in new tab, keep list intact)
+ # ============================================================
+ max_clicks = min(len(link_results), 3)
+ for idx in range(max_clicks):
+ el_r, box_r, text_r = link_results[idx]
+ print("\n[Phase {}] Ctrl+Click result #{}: '{}'".format(
+ 7 + idx, idx + 1, text_r[:50]), flush=True)
+ click_new_tab(ctx, page, el_r, box_r, text_r)
+ page.wait_for_timeout(1000)
+
+ # Done
+ print("\n" + "=" * 60, flush=True)
+ print(" ✓ Demo complete!", flush=True)
+ print(" VNC: http://localhost:6080", flush=True)
+ print("=" * 60, flush=True)
+ page.wait_for_timeout(30000)
+ browser.close()
+
+ print("Done.", flush=True)
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
+ print("Usage: python3 demo-llm-vision.py ")
+ print("")
+ print("Arguments:")
+ print(" search_url Search engine URL (default: https://www.google.com)")
+ print(" search_query What to search for (default: Yao App Engine)")
+ print("")
+ print("Environment variables (required):")
+ print(" LLM_API_KEY API key for the LLM service")
+ print(" LLM_API_BASE OpenAI-compatible endpoint URL")
+ print(" LLM_MODEL Model name/ID")
+ print("")
+ print("Examples:")
+ print(' python3 demo-llm-vision.py https://www.google.com "Yao App Engine"')
+ print(' python3 demo-llm-vision.py https://www.bing.com "Yao App Engine"')
+ print(' python3 demo-llm-vision.py https://duckduckgo.com "Yao App Engine"')
+ print(' python3 demo-llm-vision.py https://www.baidu.com "Yao App Engine"')
+ print(' python3 demo-llm-vision.py https://www.sogou.com "Yao App Engine"')
+ sys.exit(0)
+ main()