Merge pull request #1452 from trheyi/main
Add Chrome support to sandbox module and update documentation
This commit is contained in:
commit
9eeec111b9
10 changed files with 1878 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -64,3 +64,4 @@ sandbox/docker/claude/claude-proxy-*
|
||||||
sandbox/proxy/claude-proxy-linux-*
|
sandbox/proxy/claude-proxy-linux-*
|
||||||
release/*
|
release/*
|
||||||
sandbox/TODO-VNC.md
|
sandbox/TODO-VNC.md
|
||||||
|
sandbox/docker/chrome/PLAN.md
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,13 @@ The sandbox module enables Yao to safely run external AI coding agents (like Cla
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌───────────────┼───────────────┐ │
|
│ ┌───────────────┼───────────────┐ │
|
||||||
│ ▼ ▼ ▼ │
|
│ ▼ ▼ ▼ │
|
||||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
│ │ sandbox- │ │ sandbox- │ │ sandbox- │ │
|
│ │ sandbox- │ │ sandbox- │ │ sandbox- │ │ sandbox- │ │
|
||||||
│ │ claude │ │ playwright │ │ desktop │ │
|
│ │ claude │ │ browser │ │ desktop │ │ chrome │ │
|
||||||
│ │ (No VNC) │ │ (VNC) │ │ (VNC) │ │
|
│ │ (No VNC) │ │ (VNC) │ │ (VNC) │ │ (VNC) │ │
|
||||||
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
│ │ │ │ │
|
│ │ │ │ │ │
|
||||||
│ ──────┴───────────────┴───────────────┴──── │
|
│ ─────┴────────────┴────────────┴────────────┴──── │
|
||||||
│ Unix Socket IPC │
|
│ Unix Socket IPC │
|
||||||
│ (one socket per container) │
|
│ (one socket per container) │
|
||||||
└───────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
|
@ -62,6 +62,7 @@ cd sandbox/docker
|
||||||
# Build VNC-enabled images
|
# Build VNC-enabled images
|
||||||
./build.sh browser # Browser (Playwright) + Fluxbox + VNC
|
./build.sh browser # Browser (Playwright) + Fluxbox + VNC
|
||||||
./build.sh desktop # XFCE Desktop + VNC
|
./build.sh desktop # XFCE Desktop + VNC
|
||||||
|
./build.sh chrome # Real Chrome + CDP + VNC (amd64 only)
|
||||||
|
|
||||||
# Build all images
|
# Build all images
|
||||||
./build.sh all
|
./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:full` | ❌ | + Go 1.23 |
|
||||||
| `yaoapp/sandbox-claude-browser:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) |
|
| `yaoapp/sandbox-claude-browser:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) |
|
||||||
| `yaoapp/sandbox-claude-desktop:latest` | ✅ | + XFCE Desktop, VNC (~3.1GB) |
|
| `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
|
## IPC Communication
|
||||||
|
|
||||||
|
|
@ -174,6 +176,9 @@ sandbox/
|
||||||
│ ├── claude/
|
│ ├── claude/
|
||||||
│ ├── browser/ # Browser (Playwright) + VNC image
|
│ ├── browser/ # Browser (Playwright) + VNC image
|
||||||
│ ├── desktop/ # XFCE Desktop + 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
|
│ ├── vnc/ # Shared VNC scripts
|
||||||
│ └── build.sh
|
│ └── build.sh
|
||||||
├── ipc/ # IPC system
|
├── ipc/ # IPC system
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,25 @@ setup_buildx() {
|
||||||
fi
|
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 multi-arch image
|
||||||
build_multiarch() {
|
build_multiarch() {
|
||||||
local IMAGE_NAME=$1
|
local IMAGE_NAME=$1
|
||||||
|
|
@ -121,6 +140,11 @@ case $TOOL in
|
||||||
echo "=== Building Claude Desktop image ==="
|
echo "=== Building Claude Desktop image ==="
|
||||||
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
|
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)
|
cursor)
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Building Cursor images ==="
|
echo "=== Building Cursor images ==="
|
||||||
|
|
@ -135,17 +159,20 @@ case $TOOL in
|
||||||
# Claude VNC variants
|
# Claude VNC variants
|
||||||
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
|
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
|
||||||
build_multiarch "sandbox-claude-desktop" "desktop/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)
|
# Cursor (uncomment when ready)
|
||||||
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
|
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown tool: $TOOL"
|
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 # Build Claude images locally"
|
||||||
echo " $0 claude true # Build and push Claude images"
|
echo " $0 claude true # Build and push Claude images"
|
||||||
echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)"
|
echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)"
|
||||||
echo " $0 browser # Build Claude Browser image only"
|
echo " $0 browser # Build Claude Browser image only"
|
||||||
echo " $0 desktop # Build Claude Desktop 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"
|
echo " $0 all true # Build and push all images"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
|
|
@ -174,11 +201,15 @@ if [ "$PUSH" = "true" ]; then
|
||||||
desktop)
|
desktop)
|
||||||
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
|
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
|
||||||
;;
|
;;
|
||||||
|
chrome)
|
||||||
|
echo " - ${REGISTRY}/sandbox-claude-chrome:latest"
|
||||||
|
;;
|
||||||
all)
|
all)
|
||||||
echo " - ${REGISTRY}/sandbox-claude:latest"
|
echo " - ${REGISTRY}/sandbox-claude:latest"
|
||||||
echo " - ${REGISTRY}/sandbox-claude-full:latest"
|
echo " - ${REGISTRY}/sandbox-claude-full:latest"
|
||||||
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
|
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
|
||||||
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
|
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
|
||||||
|
echo " - ${REGISTRY}/sandbox-claude-chrome:latest"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
166
sandbox/docker/chrome/Dockerfile
Normal file
166
sandbox/docker/chrome/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
34
sandbox/docker/chrome/config/chrome-preferences.json
Normal file
34
sandbox/docker/chrome/config/chrome-preferences.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
44
sandbox/docker/chrome/config/stealth-init.js
Normal file
44
sandbox/docker/chrome/config/stealth-init.js
Normal file
|
|
@ -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);
|
||||||
|
};
|
||||||
105
sandbox/docker/chrome/tests/README.md
Normal file
105
sandbox/docker/chrome/tests/README.md
Normal file
|
|
@ -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.
|
||||||
349
sandbox/docker/chrome/tests/demo-baidu.py
Normal file
349
sandbox/docker/chrome/tests/demo-baidu.py
Normal file
|
|
@ -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 <h3><a href="..." target="_blank">title</a></h3>.
|
||||||
|
We need the <a> 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()
|
||||||
275
sandbox/docker/chrome/tests/demo-duckduckgo.py
Normal file
275
sandbox/docker/chrome/tests/demo-duckduckgo.py
Normal file
|
|
@ -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()
|
||||||
860
sandbox/docker/chrome/tests/demo-llm-vision.py
Normal file
860
sandbox/docker/chrome/tests/demo-llm-vision.py
Normal file
|
|
@ -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 <search_url> <search_query>
|
||||||
|
|
||||||
|
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> <search_query>
|
||||||
|
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'<think>[\s\S]*?</think>', '', 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": "<CSS selector for a VISIBLE input>", "button_selector": "<CSS selector for a VISIBLE button>"}}"""
|
||||||
|
|
||||||
|
# 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 <h3> 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": "<CSS 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 <search_url> <search_query>")
|
||||||
|
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()
|
||||||
Loading…
Add table
Reference in a new issue