From 95cdf7961c3239f19e3422adfa2903f580726189 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 6 Feb 2026 17:48:49 +0800 Subject: [PATCH] Add Chrome support to sandbox module and update documentation - Introduce a new Chrome image for the sandbox, enabling VNC access with real Chrome and CDP support (amd64 only). - Update README to reflect the addition of the Chrome image and its build instructions. - Modify build script to include a dedicated build process for the Chrome image. - Enhance .gitignore to exclude the new Chrome plan file. Co-authored-by: Cursor --- .gitignore | 1 + sandbox/README.md | 19 +- sandbox/docker/build.sh | 33 +- sandbox/docker/chrome/Dockerfile | 166 ++++ .../chrome/config/chrome-preferences.json | 34 + sandbox/docker/chrome/config/stealth-init.js | 44 + sandbox/docker/chrome/tests/README.md | 105 +++ sandbox/docker/chrome/tests/demo-baidu.py | 349 +++++++ .../docker/chrome/tests/demo-duckduckgo.py | 275 ++++++ .../docker/chrome/tests/demo-llm-vision.py | 860 ++++++++++++++++++ 10 files changed, 1878 insertions(+), 8 deletions(-) create mode 100644 sandbox/docker/chrome/Dockerfile create mode 100644 sandbox/docker/chrome/config/chrome-preferences.json create mode 100644 sandbox/docker/chrome/config/stealth-init.js create mode 100644 sandbox/docker/chrome/tests/README.md create mode 100644 sandbox/docker/chrome/tests/demo-baidu.py create mode 100644 sandbox/docker/chrome/tests/demo-duckduckgo.py create mode 100644 sandbox/docker/chrome/tests/demo-llm-vision.py 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

title

. + 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()