Skill: swaylq/browser-automation-ultra (version 1.0.0)

--- name: browser-automation-ultra description: "Zero-token browser automation via Playwright scripts with CDP lock management and human-like interaction. Use when: (1) automating any browser-based workflow (publish, login, scrape, fill forms), (2) reducing token cost by converting browser-tool explorations into replayable scripts, (3) avoiding CDP port conflicts between OpenClaw browser and Playwright, (4) needing anti-detection/human-like mouse/keyboard behavior for platforms with bot detection. NOT for: simple URL fetches (use web_fetch instead), tasks that don't need a real browser session." --- # Browser Automation Ultra Explore → Record → Replay → Fix. Convert expensive browser-tool interactions into zero-token Playwright scripts that reuse OpenClaw's Chrome session (cookies/login intact). ## Prerequisites Install Playwright (once per machine): ```bash npm install -g playwright # or in workspace: npm init -y && npm install playwright ``` No browser download needed — scripts connect to OpenClaw's existing Chrome via CDP. ## Architecture ``` Chrome user-data: ~/.openclaw/browser/openclaw/user-data ↕ shared cookies/login (mutually exclusive CDP) ┌──────────────┐ ┌──────────────────┐ │ browser tool │ OR │ Playwright script │ │ (explore) │ │ (zero token) │ └──────────────┘ └──────────────────┘ ↕ managed by browser-lock.sh ``` Only one CDP client can connect at a time. `browser-lock.sh` handles the mutex. ## Setup 1. Copy `scripts/browser-lock.sh` to your workspace `scripts/` directory 2. Copy `scripts/utils/human-like.js` to your workspace `scripts/browser/utils/` 3. `chmod +x scripts/browser-lock.sh` 4. Create `scripts/browser/` for your automation scripts ## Core Workflow ### 1. Explore (browser tool, costs tokens) Use the OpenClaw `browser` tool (snapshot/act) to figure out a workflow. Note selectors, page flow, key waits. ### 2. Record (write a Playwright script) Convert steps into a script. Save to `scripts/browser/<verb>-<target>.js`. Use the template pattern: ```javascript const { chromium } = require('playwright'); const { humanDelay, humanClick, humanType, humanThink, humanBrowse } = require('./utils/human-like'); function discoverCdpUrl() { try { const { execSync } = require('child_process'); const ps = execSync("ps aux | grep 'remote-debugging-port' | grep -v grep", { encoding: 'utf8' }); const match = ps.match(/remote-debugging-port=(\d+)/); return `http://127.0.0.1:${match ? match[1] : '18800'}`; } catch { return 'http://127.0.0.1:18800'; } } async function main() { const browser = await chromium.connectOverCDP(discoverCdpUrl()); const context = browser.contexts()[0]; // reuse existing context (cookies/login) const page = await context.newPage(); try { // automation here — use human-like functions await page.goto('https://example.com', { waitUntil: 'networkidle', timeout: 30000 }); await humanBrowse(page); // simulate looking at the page await humanClick(page, 'button.submit'); await humanType(page, 'input[name="title"]', 'Hello World'); } finally { await page.close(); // NEVER browser.close() — kills entire Chrome } } main().then(() => process.exit(0)).catch(e => { console.error('❌', e.message); process.exit(1); }); ``` ### 3. Replay (zero tokens) ```bash ./scripts/browser-lock.sh run scripts/browser/my-task.js [args] ./scripts/browser-lock.sh run --timeout 120 scripts/browser/my-task.js ``` ### 4. Fix (on error) 1. Read script error output 2. Re-explore the failing step with `browser` tool (snapshot) to check current UI 3. Update script with corrected selectors/logic 4. Retry **Never guess fixes blindly. Always re-explore the actual page state.** ## browser-lock.sh Manages CDP mutex between OpenClaw browser and Playwright scripts. ```bash ./scripts/browser-lock.sh run <script.js> [args] # acquire → run → release (300s default) ./scripts/browser-lock.sh run --timeout 120 <script> # custom timeout ./scripts/browser-lock.sh acquire # manual: stop OpenClaw browser, start Chrome ./scripts/browser-lock.sh release # manual: kill Chrome, release lock ./scripts/browser-lock.sh status # show state ``` Lock file: `/tmp/openclaw-browser.lock`. Stale locks auto-recover. ## Anti-Detection Rules (MANDATORY) All scripts **must** use `human-like.js`. See [references/anti-detection.md](references/anti-detection.md) for the full rule set. Summary of critical rules: | ❌ Banned | ✅ Required | |-----------|------------| | `waitForTimeout(3000)` fixed delays | `humanDelay(2000, 4000)` random range | | `input.fill(text)` instant fill | `humanType(page, sel, text)` char-by-char with typos | | `element.click()` teleport click | `humanClick(page, sel)` bezier mouse path + hover | | Direct page operation after load | `humanBrowse(page)` simulate reading first | | `nativeSetter.call()` DOM injection | `humanType()` or `humanFillContentEditable()` | | Fixed cron schedule | `jitterWait(1, 10)` random offset | **Exception:** `setInputFiles()` for file uploads is allowed (no human simulation possible), but add random delays before/after. ## human-like.js API | Function | Purpose | |----------|---------| | `humanDelay(min, max)` | Random wait (ms) | | `humanThink(min, max)` | Longer pause before form fills | | `humanClick(page, sel)` | Bezier mouse move → hover → click with press/release jitter | | `humanType(page, sel, text, opts)` | Char-by-char typing, normal distribution speed, 3% typo rate | | `humanFillContentEditable(page, sel, text)` | For contenteditable divs (line-by-line Enter + humanType) | | `humanBrowse(page, opts)` | Simulate page reading (scroll + mouse wander, 2-5s) | | `humanScroll(page, opts)` | Random scroll with occasional reverse | | `jitterWait(minMin, maxMin)` | Random delay in minutes for cron tasks | ## Script Naming Convention `<verb>-<target>.js` — e.g. `publish-deviantart.js`, `read-inbox.js`, `reply-comment.js` ## Example Scripts Production-tested scripts in `scripts/examples/`. Copy to your workspace `scripts/browser/` and adapt. | Script | Platform | Function | |--------|----------|----------| | `publish-deviantart.js` | DeviantArt | Upload image, fill title/desc/tags, submit | | `publish-xiaohongshu.js` | 小红书 | Publish image note with topic tag association via recommend list | | `publish-pinterest.js` | Pinterest | Create pin with title/desc, select board | | `publish-behance.js` | Behance | Upload project with title/desc/tags/categories | | `read-proton-latest.js` | Proton Mail | Read inbox, output JSON list of emails | | `read-xhs-comments.js` | 小红书 | Read notification comments, output JSON with reply button index | | `reply-xhs-comment.js` | 小红书 | Reply to a specific comment by index | Usage pattern: ```bash # Copy examples to workspace cp scripts/examples/*.js scripts/browser/ cp scripts/utils/human-like.js scripts/browser/utils/ # Run ./scripts/browser-lock.sh run scripts/browser/publish-deviantart.js image.png "Title" "Description" "tag1,tag2" ./scripts/browser-lock.sh run scripts/browser/read-xhs-comments.js --limit 10 ./scripts/browser-lock.sh run scripts/browser/reply-xhs-comment.js 0 "回复文字" ``` All example scripts already use `human-like.js` for anti-detection. ## Cron Integration ```bash cd /path/to/workspace && ./scripts/browser-lock.sh run scripts/browser/task.js ``` Add `jitterWait()` at script start to randomize execution time. ## Troubleshooting | Problem | Fix | |---------|-----| | Lock held by PID xxx | `./scripts/browser-lock.sh release` | | CDP connection timeout | Ensure `acquire` was called / Chrome is running | | Login expired | Use browser tool to re-login, then run script | | Selector not found | Re-explore with browser tool, update script | | Script timeout | Increase with `--timeout` flag | ## Environment Variables | Var | Default | Description | |-----|---------|-------------| | `CDP_PORT` | auto-discover | Override CDP port | | `CHROME_BIN` | auto-detect | Chrome binary path | | `HEADLESS` | auto | `true`/`false` to force headless |

path: references/anti-detection.md
content: # Anti-Detection Rules for Browser Automation Scripts

## Why This Matters

Platforms like Xiaohongshu, DeviantArt, Pinterest, Behance use behavioral analysis to detect automation. Violations result in shadow bans, captcha loops, or account suspension.

## Rules

### 1. No Fixed Delays

```javascript
// ❌ NEVER
await page.waitForTimeout(3000);

// ✅ ALWAYS
await humanDelay(2000, 4000);
await humanThink(1000, 3000); // before form fills
```

### 2. No Instant Text Injection

```javascript
// ❌ NEVER — no keydown/keyup events, detected by frontend monitoring
await input.fill(text);
await page.evaluate(() => { input.value = text; });
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
nativeSetter.call(input, text);

// ✅ ALWAYS — real keystroke events with variable timing
await humanType(page, 'input[name="title"]', text);
await humanFillContentEditable(page, '[contenteditable="true"]', text);
```

### 3. No Teleport Clicks

```javascript
// ❌ NEVER — coordinates jump instantly, no mousemove events
await element.click();
await page.click('button.submit');

// ✅ ALWAYS — bezier curve mouse path → random hover → press/release jitter
await humanClick(page, 'button.submit');
await humanClick(page, elementHandle); // also accepts element handles
```

### 4. Page Browsing Simulation Required

```javascript
// ❌ NEVER — open page then immediately operate
await page.goto(url);
await page.fill('input', text); // too fast

// ✅ ALWAYS — simulate human reading the page first
await page.goto(url, { waitUntil: 'networkidle' });
await humanDelay(2000, 4000);  // page load settling
await humanBrowse(page);       // scroll + mouse wander
await humanThink(800, 2000);   // pause before action
await humanType(page, 'input', text);
```

### 5. Cron Time Jitter

```javascript
// ❌ NEVER — publish at exact same time every day
// cron: 0 10 * * *

// ✅ ALWAYS — add random offset at script start
const { jitterWait } = require('./utils/human-like');
await jitterWait(1, 15); // random 1-15 minute delay
```

### 6. page.evaluate() Usage

```javascript
// ✅ OK — reading DOM state
const count = await page.evaluate(() => document.querySelectorAll('.item').length);

// ✅ OK — clicking from a dynamic list (no other way)
await page.evaluate((idx) => document.querySelectorAll('.tag')[idx].click(), index);

// ❌ NEVER — injecting text or triggering form submission
await page.evaluate((t) => { document.querySelector('input').value = t; }, text);
```

### 7. setInputFiles Exception

File upload has no human-like alternative. Direct call allowed:

```javascript
await fileInput.setInputFiles(path);
```

But always add random delays around it:

```javascript
await humanThink(500, 1500);
await fileInput.setInputFiles(imagePath);
await humanDelay(3000, 6000); // wait for upload processing
```

## human-like.js Function Reference

### humanDelay(minMs, maxMs) → Promise<number>
Random delay using uniform distribution. Returns actual ms waited.

### humanThink(minMs, maxMs) → Promise<number>
Alias for longer delays (default 1500-4000ms). Use before form interactions.

### humanClick(page, selectorOrElement, opts?) → Promise<void>
1. Resolves element bounding box
2. Picks random point within element (not always center)
3. Generates bezier curve mouse path from random viewport position
4. Moves mouse along path with 3-12ms steps
5. Hovers 80-250ms
6. mousedown → 40-120ms → mouseup
7. Post-click pause 100-400ms

Options: `{ timeout: 10000, button: 'left' }`

### humanType(page, selector, text, opts?) → Promise<void>
1. If selector provided, humanClick on it first
2. Types each character with gaussian-distributed delay
3. 3% chance of typo (adjacent QWERTY key) → auto-backspace → correct key
4. Extra pause after punctuation/spaces (30% chance)

Options: `{ minDelay: 50, maxDelay: 180, typoRate: 0.03 }`

### humanFillContentEditable(page, selector, text, opts?) → Promise<void>
humanClick on element, then type line-by-line with Enter between lines.

### humanBrowse(page, opts?) → Promise<void>
Simulates 2-5s of page browsing: random scrolls + mouse movements + pauses.

### humanScroll(page, opts?) → Promise<void>
2-5 scroll events with random direction (80% down, 20% up). 500-2000ms between scrolls.

### jitterWait(minMinutes, maxMinutes) → Promise<number>
Waits random minutes. Logs the wait time. Returns ms waited.

### jitterSchedule(baseMinutes, range) → number
Returns baseMinutes ± range (for schedule calculation, not waiting).


path: scripts/browser-lock.sh
content: #!/bin/bash
# browser-lock.sh — OpenClaw browser ↔ Playwright mutex manager
#
# Usage:
#   browser-lock.sh acquire                    — stop OpenClaw browser, start standalone Chrome, take lock
#   browser-lock.sh release                    — kill standalone Chrome, release lock
#   browser-lock.sh run [--timeout S] <script> [args...]  — acquire → run script → release
#   browser-lock.sh status                     — show current state
#
# Environment:
#   CDP_PORT    (default: 18800)
#   CHROME_BIN  (default: auto-detect)
#   HEADLESS    (default: auto — headless if no DISPLAY/macOS)

set -euo pipefail

LOCK_FILE="/tmp/openclaw-browser.lock"
CDP_PORT="${CDP_PORT:-18800}"
USER_DATA_DIR="$HOME/.openclaw/browser/openclaw/user-data"
PID_FILE="/tmp/openclaw-browser-standalone.pid"
DEFAULT_TIMEOUT=300  # 5 minutes

# --- Auto-detect Chrome ---
if [ -z "${CHROME_BIN:-}" ]; then
  if [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then
    CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
  elif command -v google-chrome &>/dev/null; then
    CHROME_BIN="google-chrome"
  elif command -v chromium-browser &>/dev/null; then
    CHROME_BIN="chromium-browser"
  elif command -v chromium &>/dev/null; then
    CHROME_BIN="chromium"
  else
    echo "❌ Chrome not found. Set CHROME_BIN." >&2
    exit 1
  fi
fi

# --- Auto-detect headless ---
needs_headless() {
  if [ "${HEADLESS:-}" = "true" ] || [ "${HEADLESS:-}" = "1" ]; then
    return 0
  fi
  if [ "${HEADLESS:-}" = "false" ] || [ "${HEADLESS:-}" = "0" ]; then
    return 1
  fi
  # Auto: headless if no display (Linux without X/Wayland)
  if [ "$(uname)" = "Linux" ] && [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then
    return 0
  fi
  return 1
}

# --- Lock management ---
# Lock file format: "PID CHROME_PID TIMESTAMP"
write_lock() {
  local chrome_pid="${1:-0}"
  echo "$$ $chrome_pid $(date +%s)" > "$LOCK_FILE"
}

read_lock() {
  # outputs: shell_pid chrome_pid timestamp
  if [ -f "$LOCK_FILE" ]; then
    cat "$LOCK_FILE"
  fi
}

is_lock_alive() {
  if [ ! -f "$LOCK_FILE" ]; then
    return 1
  fi
  local info
  info=$(read_lock)
  local shell_pid chrome_pid timestamp
  shell_pid=$(echo "$info" | awk '{print $1}')
  chrome_pid=$(echo "$info" | awk '{print $2}')
  # Lock is alive if either the shell or chrome process is running
  if kill -0 "$shell_pid" 2>/dev/null || kill -0 "$chrome_pid" 2>/dev/null; then
    return 0
  fi
  return 1
}

check_lock_timeout() {
  if [ ! -f "$LOCK_FILE" ]; then
    return
  fi
  local info
  info=$(read_lock)
  local timestamp now age
  timestamp=$(echo "$info" | awk '{print $3}')
  if [ -z "$timestamp" ]; then return; fi
  now=$(date +%s)
  age=$(( now - timestamp ))
  if [ "$age" -gt "$DEFAULT_TIMEOUT" ]; then
    echo "⚠️ Lock is ${age}s old (timeout: ${DEFAULT_TIMEOUT}s), force-releasing..."
    release
  fi
}

kill_cdp_chrome() {
  local pids
  pids=$(ps aux | grep "remote-debugging-port=$CDP_PORT" | grep -v grep | awk '{print $2}' || true)
  if [ -n "$pids" ]; then
    echo "⏹ Stopping Chrome on CDP port $CDP_PORT (PIDs: $pids)..."
    echo "$pids" | xargs kill 2>/dev/null || true
    sleep 1
    for pid in $pids; do
      kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true
    done
  fi
}

start_chrome() {
  if curl -s --max-time 1 "http://127.0.0.1:$CDP_PORT/json/version" &>/dev/null; then
    echo "⚠️ CDP port $CDP_PORT already in use"
    # Grab the existing Chrome PID for the lock
    local existing_pid
    existing_pid=$(ps aux | grep "remote-debugging-port=$CDP_PORT" | grep -v grep | awk '{print $2}' | head -1 || echo 0)
    echo "$existing_pid" > "$PID_FILE"
    return 0
  fi

  local headless_flag=""
  if needs_headless; then
    headless_flag="--headless=new"
    echo "🖥 Headless mode"
  fi

  echo "🚀 Starting Chrome on CDP port $CDP_PORT..."
  "$CHROME_BIN" \
    --remote-debugging-port="$CDP_PORT" \
    --user-data-dir="$USER_DATA_DIR" \
    --no-first-run \
    --no-default-browser-check \
    --disable-sync \
    --disable-background-networking \
    --disable-component-update \
    --disable-features=Translate,MediaRouter \
    --disable-session-crashed-bubble \
    --hide-crash-restore-bubble \
    --password-store=basic \
    --disable-blink-features=AutomationControlled \
    $headless_flag \
    about:blank &>/dev/null &

  local chrome_pid=$!
  echo "$chrome_pid" > "$PID_FILE"

  for i in $(seq 1 10); do
    if curl -s --max-time 1 "http://127.0.0.1:$CDP_PORT/json/version" &>/dev/null; then
      echo "✅ Chrome ready (PID: $chrome_pid, CDP: $CDP_PORT)"
      return 0
    fi
    sleep 0.5
  done
  echo "❌ Chrome failed to start" >&2
  return 1
}

acquire() {
  # Check existing lock
  if [ -f "$LOCK_FILE" ]; then
    if is_lock_alive; then
      # Check if it's timed out
      local info timestamp now age
      info=$(read_lock)
      timestamp=$(echo "$info" | awk '{print $3}')
      now=$(date +%s)
      age=$(( now - ${timestamp:-0} ))
      if [ "$age" -gt "$DEFAULT_TIMEOUT" ]; then
        echo "⚠️ Lock is ${age}s old (timeout: ${DEFAULT_TIMEOUT}s), force-releasing..."
        release
      else
        echo "❌ Lock held ($(cat "$LOCK_FILE")). Age: ${age}s. Use 'release' to force." >&2
        exit 1
      fi
    else
      echo "⚠️ Stale lock (processes dead), cleaning..."
      rm -f "$LOCK_FILE"
    fi
  fi

  kill_cdp_chrome
  sleep 1
  start_chrome

  # Write lock with Chrome PID
  local chrome_pid
  chrome_pid=$(cat "$PID_FILE" 2>/dev/null || echo 0)
  write_lock "$chrome_pid"
}

release() {
  if [ -f "$PID_FILE" ]; then
    local pid
    pid=$(cat "$PID_FILE")
    if kill -0 "$pid" 2>/dev/null; then
      echo "⏹ Stopping standalone Chrome (PID: $pid)..."
      kill "$pid" 2>/dev/null || true
      sleep 1
      kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true
    fi
    rm -f "$PID_FILE"
  fi
  kill_cdp_chrome
  rm -f "$LOCK_FILE"
  echo "🔓 Released. OpenClaw browser can restart via 'browser start'."
}

run_script() {
  local timeout=$DEFAULT_TIMEOUT

  # Parse --timeout flag
  if [ "${1:-}" = "--timeout" ]; then
    timeout="$2"
    shift 2
  fi

  if [ $# -lt 1 ]; then
    echo "Usage: browser-lock.sh run [--timeout S] <script.js> [args...]" >&2
    exit 1
  fi

  acquire

  local exit_code=0
  echo "▶ Running (timeout: ${timeout}s): node $*"

  # Run with timeout — kill on expiry
  local script_pid
  node "$@" &
  script_pid=$!

  # Update lock with script PID so liveness checks work
  local chrome_pid
  chrome_pid=$(cat "$PID_FILE" 2>/dev/null || echo 0)
  echo "$script_pid $chrome_pid $(date +%s)" > "$LOCK_FILE"

  # Background watchdog
  (
    sleep "$timeout"
    if kill -0 "$script_pid" 2>/dev/null; then
      echo "⏰ Timeout (${timeout}s) — killing script PID $script_pid" >&2
      kill "$script_pid" 2>/dev/null
      sleep 2
      kill -0 "$script_pid" 2>/dev/null && kill -9 "$script_pid" 2>/dev/null
    fi
  ) &
  local watchdog_pid=$!

  wait "$script_pid" || exit_code=$?

  # Kill watchdog if script finished before timeout
  kill "$watchdog_pid" 2>/dev/null || true
  wait "$watchdog_pid" 2>/dev/null || true

  release

  [ $exit_code -ne 0 ] && echo "❌ Script exited with code $exit_code"
  return $exit_code
}

status() {
  echo "--- Browser Lock Status ---"
  if [ -f "$LOCK_FILE" ]; then
    local info shell_pid chrome_pid timestamp now age
    info=$(read_lock)
    shell_pid=$(echo "$info" | awk '{print $1}')
    chrome_pid=$(echo "$info" | awk '{print $2}')
    timestamp=$(echo "$info" | awk '{print $3}')
    now=$(date +%s)
    age=$(( now - ${timestamp:-0} ))

    if is_lock_alive; then
      echo "🔒 Locked (script: $shell_pid, chrome: $chrome_pid, age: ${age}s, timeout: ${DEFAULT_TIMEOUT}s)"
    else
      echo "⚠️ Stale lock (both PIDs dead, age: ${age}s)"
    fi
  else
    echo "🔓 Unlocked"
  fi
  if curl -s --max-time 1 "http://127.0.0.1:$CDP_PORT/json/version" &>/dev/null; then
    echo "🌐 Chrome running on CDP port $CDP_PORT"
  else
    echo "⭕ No Chrome on CDP port $CDP_PORT"
  fi
}

case "${1:-status}" in
  acquire) acquire ;;
  release) release ;;
  run)     shift; run_script "$@" ;;
  status)  status ;;
  *)       echo "Usage: browser-lock.sh {acquire|release|run [--timeout S] <script.js>|status}" >&2; exit 1 ;;
esac


path: scripts/examples/publish-behance.js
content: #!/usr/bin/env node
/**
 * Behance Publish Script (Playwright via CDP)
 * Usage: ./scripts/browser-lock.sh run scripts/browser/publish-behance.js <image-path> <title> <description> <tags-csv> <categories-csv>
 * 
 * Categories: comma-separated Chinese names matching Behance's category list
 * Default categories: 数码艺术,插图,美术
 * 
 * Connects to standalone Chrome launched by browser-lock.sh.
 * Requires: Behance (Adobe) session already logged in.
 */

const { chromium } = require('playwright');
const { execSync } = require('child_process');
const path = require('path');
const { humanDelay, humanClick, humanType, humanThink, humanBrowse } = require('./utils/human-like');

function discoverCdpUrl() {
  try {
    const ps = execSync("ps aux | grep 'remote-debugging-port=' | grep -v grep", { encoding: 'utf8', timeout: 3000 });
    const match = ps.match(/remote-debugging-port=(\d+)/);
    if (match) return `http://127.0.0.1:${match[1]}`;
  } catch {}
  if (process.env.CDP_PORT) return `http://127.0.0.1:${process.env.CDP_PORT}`;
  for (const port of [18800, 9222]) {
    try {
      execSync(`curl -s --max-time 1 http://127.0.0.1:${port}/json/version`, { encoding: 'utf8', timeout: 2000 });
      return `http://127.0.0.1:${port}`;
    } catch {}
  }
  throw new Error('CDP port not found');
}

function log(msg) { console.log(`[BE] ${msg}`); }

async function main() {
  const [,, imagePath, title, description, tagsStr, categoriesStr] = process.argv;
  if (!imagePath || !title) {
    console.error('Usage: node publish-behance.js <image> <title> [description] [tags] [categories]');
    process.exit(1);
  }

  const absPath = path.resolve(imagePath);
  const tags = (tagsStr || 'digital art,abstract art,generative art').split(',').map(t => t.trim());
  const categories = (categoriesStr || '数码艺术,插图,美术').split(',').map(c => c.trim());
  const desc = description || title;

  log(`Publishing: "${title}"`);
  log(`Tags: ${tags.join(', ')}`);
  log(`Categories: ${categories.join(', ')}`);

  let browser;
  try {
    browser = await chromium.connectOverCDP(discoverCdpUrl());
  } catch (e) {
    console.error('❌ Cannot connect to CDP:', e.message);
    process.exit(1);
  }

  const context = browser.contexts()[0];
  const page = await context.newPage();

  try {
    // ===== 1. Open editor =====
    await page.goto('https://www.behance.net/portfolio/editor', { waitUntil: 'networkidle', timeout: 30000 });
    await humanDelay(2000, 4000);

    // Dismiss popups
    for (let i = 0; i < 3; i++) {
      const closed = await page.evaluate(() => {
        const btns = [...document.querySelectorAll('button, a')];
        const dismiss = btns.find(b => /Continue to editor|继续转到编辑器|Maybe later|稍后/.test(b.textContent));
        if (dismiss) { dismiss.click(); return true; }
        return false;
      });
      if (closed) await humanDelay(800, 1500);
      else break;
    }
    log('Editor opened');
    await humanBrowse(page, { duration: 2000 });

    // ===== 2. Upload image via file input (qqfile) =====
    const addImgBtn = await page.$('button:has-text("Add an Image"), button:has-text("Add Photo")');
    if (addImgBtn) await humanClick(page, addImgBtn);
    await humanDelay(800, 1500);

    // Use the hidden file input
    const fileInput = await page.$('input[name="qqfile"]');
    if (fileInput) {
      await fileInput.setInputFiles(absPath);
      log('Image uploaded via file input');
    } else {
      // Fallback: use filechooser event
      const [fileChooser] = await Promise.all([
        page.waitForEvent('filechooser', { timeout: 10000 }),
        page.click('button:has-text("图像"), button:has-text("Image"), button:has-text("添加图像")')
      ]);
      await fileChooser.setFiles(absPath);
      log('Image uploaded via filechooser');
    }

    await humanDelay(6000, 10000);
    log('Upload processing complete');

    // ===== 3. Click "继续" (Continue) =====
    await humanThink(1000, 2000);
    const continueBtn = await page.$('button:has-text("继续"), button:has-text("Continue")');
    if (continueBtn) {
      await humanClick(page, continueBtn);
      log('Clicked Continue');
    } else {
      throw new Error('Continue button not found');
    }
    await humanDelay(2000, 4000);

    // ===== 4. Fill publish dialog fields =====
    // Wait for the dialog
    await page.waitForSelector('dialog, [role="dialog"]', { timeout: 10000 });
    log('Publish dialog opened');

    // 4a. Title
    await humanThink(500, 1500);
    const titleInput = await page.$('input[placeholder*="标题"], input[placeholder*="title"], input[placeholder*="Title"], input[placeholder*="项目"]');
    if (titleInput) {
      await humanClick(page, titleInput);
      await page.keyboard.down('Meta');
      await page.keyboard.press('a');
      await page.keyboard.up('Meta');
      await humanDelay(100, 300);
      await humanType(page, null, title, { minDelay: 50, maxDelay: 160 });
      log(`Title set: ${title}`);
    } else {
      log('WARNING: Title input not found');
    }

    // 4b. Tags
    await humanThink(300, 800);
    const tagInputs = await page.$$('input[placeholder*="关键字"], input[placeholder*="tag"], input[placeholder*="Tag"]');
    let tagInput = tagInputs[0];
    if (!tagInput) tagInput = await page.$('input[placeholder=""]');

    if (tagInput) {
      for (const tag of tags) {
        await humanClick(page, tagInput);
        await humanType(page, null, tag, { minDelay: 40, maxDelay: 120 });
        await page.keyboard.press('Enter');
        await humanDelay(300, 700);
      }
      log(`Tags added: ${tags.join(', ')}`);
    } else {
      log('WARNING: Tag input not found');
    }

    // 4c. Categories — click "查看全部" to open category modal
    const viewAllBtn = await page.$('button:has-text("查看全部"), button:has-text("View All")');
    if (viewAllBtn) {
      await viewAllBtn.click();
      await page.waitForTimeout(1500);
      log('Category modal opened');

      // Select categories by checkbox label
      for (const cat of categories) {
        const checkbox = await page.$(`text="${cat}" >> xpath=.. >> input[type="checkbox"], label:has-text("${cat}") input[type="checkbox"]`);
        if (checkbox) {
          await checkbox.check();
          log(`Category selected: ${cat}`);
        } else {
          // Fallback: find checkbox by nearby text
          const checked = await page.evaluate((catName) => {
            const labels = [...document.querySelectorAll('label, span, div')];
            for (const el of labels) {
              if (el.textContent.trim() === catName) {
                const cb = el.closest('label, div')?.querySelector('input[type="checkbox"]') 
                  || el.previousElementSibling;
                if (cb && cb.type === 'checkbox' && !cb.checked) {
                  cb.click();
                  return true;
                }
              }
            }
            return false;
          }, cat);
          log(checked ? `Category selected (fallback): ${cat}` : `WARNING: Category not found: ${cat}`);
        }
        await humanDelay(300, 700);
      }

      const doneBtn = await page.$('button:has-text("完成"), button:has-text("Done")');
      if (doneBtn) {
        await humanClick(page, doneBtn);
        await humanDelay(800, 1500);
        log('Category selection done');
      }
    } else {
      log('WARNING: Category button not found');
    }

    // 4d. Description
    await humanThink(500, 1200);
    const descInput = await page.$('textarea[placeholder*="描述"], textarea[placeholder*="description"], textarea[placeholder*="Description"]');
    if (descInput) {
      await humanClick(page, descInput);
      await humanDelay(200, 500);
      await humanType(page, null, desc, { minDelay: 30, maxDelay: 100 });
      log('Description set');
    } else {
      log('WARNING: Description textarea not found');
    }

    // ===== 5. Click "发布" (Publish) =====
    await humanThink(1500, 3000);
    
    const publishBtn = await page.$('button:has-text("发布"):not(:has-text("安排"))');
    if (publishBtn) {
      // Wait for it to be enabled
      await page.waitForFunction(() => {
        const btns = [...document.querySelectorAll('button')];
        const pub = btns.find(b => b.textContent.trim() === '发布' || b.textContent.trim() === 'Publish');
        return pub && !pub.disabled;
      }, {}, { timeout: 15000 });
      
      await humanClick(page, publishBtn);
      log('Clicked Publish');
    } else {
      throw new Error('Publish button not found');
    }

    await humanDelay(6000, 10000);

    // ===== 6. Verify =====
    const finalUrl = page.url();
    log(`Final URL: ${finalUrl}`);

    if (finalUrl.includes('gallery') || finalUrl.includes('freeonart') || !finalUrl.includes('editor')) {
      log('✅ SUCCESS — Project published!');
    } else {
      await page.screenshot({ path: '/tmp/be-publish-result.png' });
      log('⚠️ Check /tmp/be-publish-result.png for status');
    }

  } catch (error) {
    console.error('❌ Error:', error.message);
    await page.screenshot({ path: '/tmp/be-publish-error.png' }).catch(() => {});
    throw error;
  } finally {
    await page.close();
  }
}

main().then(() => process.exit(0)).catch(e => {
  console.error('❌', e.message);
  process.exit(1);
});


path: scripts/examples/publish-deviantart.js
content: #!/usr/bin/env node
/**
 * DeviantArt Publish Script (Playwright via CDP)
 * Usage: node publish-deviantart.js <image-path> <title> <description> <tags-comma-separated>
 * 
 * Connects to OpenClaw's managed browser.
 * Requires: DA session already logged in.
 */

const { chromium } = require('playwright');
const { execSync } = require('child_process');
const path = require('path');
const { humanDelay, humanClick, humanType, humanThink, humanBrowse, humanScroll } = require('./utils/human-like');

function getCdpUrl() {
  try {
    const ps = execSync("ps aux | grep 'openclaw.*remote-debugging-port' | grep -v grep", { encoding: 'utf8' });
    const match = ps.match(/remote-debugging-port=(\d+)/);
    return `http://127.0.0.1:${match ? match[1] : '18800'}`;
  } catch { return 'http://127.0.0.1:18800'; }
}

function log(msg) { console.log(`[DA] ${msg}`); }
function err(msg) { console.error(`[DA ERROR] ${msg}`); }

async function main() {
  const [,, imagePath, title, description, tagsStr] = process.argv;
  if (!imagePath || !title) {
    err('Usage: node publish-deviantart.js <image> <title> [description] [tags]');
    process.exit(1);
  }

  const absPath = path.resolve(imagePath);
  const tags = (tagsStr || 'digitalart,abstractart').split(',').map(t => t.trim());
  const desc = description || title;

  log(`Publishing: "${title}"`);
  log(`Image: ${absPath}`);
  log(`Tags: ${tags.join(', ')}`);

  let browser;
  try {
    browser = await chromium.connectOverCDP(getCdpUrl());
  } catch (e) {
    err('Cannot connect to CDP. Is OpenClaw browser running?');
    process.exit(1);
  }

  const context = browser.contexts()[0];
  const page = await context.newPage();

  try {
    // ===== 1. Navigate to submit page =====
    await page.goto('https://www.deviantart.com/studio?new=1', { waitUntil: 'networkidle', timeout: 30000 });
    await humanDelay(2000, 4000);
    log('Submit dialog loaded');

    // ===== 2. Upload image =====
    // DA requires clicking "Upload Your Art" first, then file input becomes active
    // Use Promise.all to handle file chooser event
    const [fileChooser] = await Promise.all([
      page.waitForEvent('filechooser', { timeout: 10000 }),
      page.evaluate(() => {
        // Click "Upload Your Art" button
        const btns = [...document.querySelectorAll('button')];
        const uploadBtn = btns.find(b => b.textContent.includes('Upload Your Art'));
        if (uploadBtn) uploadBtn.click();
      })
    ]);
    await fileChooser.setFiles(absPath);
    log('Image uploaded via file chooser');

    // Wait for image processing
    await humanDelay(5000, 8000);

    // ===== 3. Verify upload succeeded =====
    // After upload, form fields should appear. Take screenshot to verify.
    await page.screenshot({ path: '/tmp/da-after-upload.png' });

    // ===== 4. Fill title =====
    await humanThink(800, 2000);
    // Find the title input (has auto-filled filename)
    const titleInputSelector = await page.evaluate(() => {
      const allInputs = document.querySelectorAll('input[type="text"]');
      for (const input of allInputs) {
        if (input.placeholder === 'Search') continue;
        if (input.closest('[role="menu"]')) continue;
        const parent = input.closest('div');
        const hasLabel = parent?.textContent?.includes('Title');
        if (input.value !== '' || hasLabel) {
          // Add a temporary id for targeting
          input.setAttribute('data-da-title', 'true');
          return 'found';
        }
      }
      return 'not_found';
    });

    if (titleInputSelector === 'found') {
      const titleEl = await page.$('input[data-da-title="true"]');
      if (titleEl) {
        await humanClick(page, titleEl);
        await page.keyboard.down('Meta');
        await page.keyboard.press('a');
        await page.keyboard.up('Meta');
        await humanDelay(100, 300);
        await humanType(page, null, title, { minDelay: 40, maxDelay: 150 });
        log(`Title: ${title}`);
      }
    } else {
      log('Title: not_found');
    }

    // ===== 5. Add tags =====
    // Tag input is inside a combobox. Search entire page.
    const tagInputInfo = await page.evaluate(() => {
      const combobox = document.querySelector('[role="combobox"]');
      if (combobox) {
        const input = combobox.querySelector('input');
        if (input) { input.focus(); return `combobox: ${input.className.substring(0, 30)}`; }
      }
      // Fallback: find input near "tag" text
      const allInputs = document.querySelectorAll('input[type="text"]');
      for (const inp of allInputs) {
        if (inp.placeholder === 'Search') continue;
        const parent = inp.closest('div');
        if (parent?.textContent?.includes('tag') || parent?.textContent?.includes('Tag')) {
          inp.focus();
          return `near-tag: ${inp.className.substring(0, 30)}`;
        }
      }
      return 'not_found';
    });
    log(`Tag input: ${tagInputInfo}`);

    if (!tagInputInfo.includes('not_found')) {
      const existingTags = await page.evaluate(() => {
        const chips = document.querySelectorAll('[class*="tag"] [class*="chip"], [class*="tag"] button');
        return [...chips].map(c => c.textContent.replace('✕', '').trim().toLowerCase()).filter(t => t);
      });
      log(`Existing tags: ${existingTags.join(', ') || 'none'}`);

      for (const tag of tags) {
        if (existingTags.includes(tag.toLowerCase())) {
          log(`  Skipping duplicate tag: ${tag}`);
          continue;
        }
        await page.evaluate(() => {
          const combobox = document.querySelector('[role="combobox"]');
          if (combobox) { const input = combobox.querySelector('input'); if (input) input.focus(); }
        });
        await humanDelay(200, 500);
        await humanType(page, null, tag, { minDelay: 40, maxDelay: 120, typoRate: 0.02 });
        await page.keyboard.press('Enter');
        await humanDelay(300, 800);
      }
      log(`Tags typed: ${tags.join(', ')}`);
    }

    // ===== 6. Fill description =====
    await humanThink(500, 1500);
    // Find the contenteditable for description and mark it
    const descFound = await page.evaluate(() => {
      const editables = document.querySelectorAll('[contenteditable="true"]');
      for (const ed of editables) {
        if (ed.offsetHeight < 50) continue;
        ed.setAttribute('data-da-desc', 'true');
        return true;
      }
      return false;
    });
    if (descFound) {
      await humanFillContentEditable(page, '[data-da-desc="true"]', desc, { minDelay: 30, maxDelay: 120 });
      log('Description filled');
    } else {
      log('Description: not_found');
    }

    // ===== 7. Screenshot before submit =====
    await humanThink(1000, 2500); // 发布前检查停顿
    await page.screenshot({ path: '/tmp/da-before-submit.png' });

    // ===== 8. Click Submit (NOT "Save to Studio") =====
    // The dialog has two buttons at the bottom. "Submit" is the primary action.
    // Strategy: find all buttons with text "Submit", exclude nav/menu ones
    const submitResult = await page.evaluate(() => {
      const btns = [...document.querySelectorAll('button')];
      
      // Collect all Submit buttons with context info
      const submitBtns = btns.filter(b => {
        const text = b.textContent.trim();
        if (text !== 'Submit') return false;
        // Exclude header nav submit button
        if (b.closest('nav') || b.closest('header') || b.closest('[role="menu"]')) return false;
        // Exclude the site header submit dropdown trigger
        if (b.closest('[class*="site-header"]')) return false;
        return true;
      });

      if (submitBtns.length === 0) return 'no_submit_found';

      // If multiple, pick the one inside the dialog or modal
      let target = submitBtns.find(b => b.closest('[role="dialog"]') || b.closest('[class*="modal"]'));
      if (!target) target = submitBtns[submitBtns.length - 1]; // fallback: last one

      target.click();
      return `clicked (class: ${target.className.substring(0, 30)})`;
    });
    log(`Submit: ${submitResult}`);

    await humanDelay(4000, 7000);

    // ===== 9. Verify =====
    await page.screenshot({ path: '/tmp/da-after-submit.png' });
    
    // Check if it went to published or if dialog closed
    const finalState = await page.evaluate(() => {
      const dialog = document.querySelector('[role="dialog"]');
      const errorMsg = document.querySelector('[class*="error" i]');
      return {
        dialogOpen: !!dialog,
        hasError: !!errorMsg,
        errorText: errorMsg?.textContent?.substring(0, 100) || '',
        url: window.location.href
      };
    });
    
    log(`Final state: dialog=${finalState.dialogOpen}, error=${finalState.hasError}`);
    if (finalState.hasError) log(`Error: ${finalState.errorText}`);
    
    // Navigate to published to verify
    await page.goto('https://www.deviantart.com/studio/published', { waitUntil: 'networkidle', timeout: 15000 });
    const topItem = await page.evaluate(() => {
      const firstBtn = document.querySelector('li button[class*="IIhy6Y"]');
      return firstBtn ? firstBtn.textContent.trim() : 'unknown';
    });
    log(`Top published item: "${topItem}"`);
    
    if (topItem.toLowerCase().includes(process.argv[3]?.toLowerCase()?.substring(0, 10) || '???')) {
      log('SUCCESS - Verified in published list');
    } else {
      log('WARNING - Could not verify in published list. Check /tmp/da-after-submit.png');
    }

  } catch (error) {
    err(error.message);
    await page.screenshot({ path: '/tmp/da-publish-error.png' }).catch(() => {});
    process.exit(1);
  } finally {
    await page.close();
  }
}

main();


path: scripts/examples/publish-pinterest.js
content: #!/usr/bin/env node
/**
 * Pinterest Publish Script (Playwright)
 * Usage: node publish-pinterest.js <image-path> <title> <description> [board-name]
 * 
 * Connects to OpenClaw's managed browser via CDP.
 * Requires: Pinterest session already logged in.
 */

const { chromium } = require('playwright');
const { execSync } = require('child_process');
const { humanDelay, humanClick, humanType, humanThink, humanBrowse, humanScroll } = require('./utils/human-like');

function discoverCdpUrl() {
  try {
    const ps = execSync("ps aux | grep 'remote-debugging-port=' | grep -v grep", { encoding: 'utf8', timeout: 3000 });
    const match = ps.match(/remote-debugging-port=(\d+)/);
    if (match) return `http://127.0.0.1:${match[1]}`;
  } catch {}
  if (process.env.CDP_PORT) return `http://127.0.0.1:${process.env.CDP_PORT}`;
  for (const port of [18800, 9222, 9229]) {
    try {
      execSync(`curl -s --max-time 1 http://127.0.0.1:${port}/json/version`, { encoding: 'utf8', timeout: 2000 });
      return `http://127.0.0.1:${port}`;
    } catch {}
  }
  throw new Error('CDP port not found');
}

async function main() {
  const [,, imagePath, title, description, boardName] = process.argv;
  if (!imagePath || !title) {
    console.error('Usage: node publish-pinterest.js <image> <title> [description] [board]');
    process.exit(1);
  }

  const desc = description || title;
  const board = boardName || 'Abstract Digital Art';

  console.log(`[PIN] Publishing: "${title}"`);
  console.log(`[PIN] Image: ${imagePath}`);

  let browser;
  try {
    browser = await chromium.connectOverCDP(discoverCdpUrl());
  } catch (e) {
    console.error('[PIN] Cannot connect to CDP. Is OpenClaw browser running?');
    process.exit(1);
  }

  const context = browser.contexts()[0];
  const page = await context.newPage();

  try {
    await page.goto('https://www.pinterest.com/pin-creation-tool/', { waitUntil: 'networkidle', timeout: 30000 });
    await humanDelay(2000, 4000);

    // 1. Upload image via file input
    const fileInput = await page.waitForSelector('#storyboard-upload-input, input[type="file"]', { timeout: 10000 });
    await fileInput.setInputFiles(imagePath);
    console.log('[PIN] Image uploaded');
    await humanDelay(3000, 6000);

    // 2. Fill title
    await humanThink(800, 2000);
    const titleInput = await page.waitForSelector('input[placeholder*="标题" i]:not([disabled]), input[placeholder*="title" i]:not([disabled])', { timeout: 10000 }).catch(() => null);
    if (titleInput) {
      await humanClick(page, titleInput);
      await page.keyboard.down('Meta');
      await page.keyboard.press('a');
      await page.keyboard.up('Meta');
      await humanDelay(100, 300);
      await humanType(page, null, title, { minDelay: 50, maxDelay: 160 });
      console.log(`[PIN] Title: ${title}`);
    } else {
      console.log('[PIN] WARN: title input not found');
    }

    // 3. Fill description (Draft.js editor)
    await humanThink(500, 1500);
    const descEditor = await page.waitForSelector('.public-DraftEditor-content', { timeout: 5000 }).catch(() => null);
    if (descEditor) {
      await humanClick(page, descEditor);
      await humanDelay(200, 500);
      await humanType(page, null, desc, { minDelay: 40, maxDelay: 130 });
      console.log('[PIN] Description set');
    } else {
      console.log('[PIN] WARN: description editor not found');
    }

    // 4. Select board - click the board dropdown
    const boardBtn = await page.evaluate(() => {
      // Find button containing "选择一块图板" or "Select board" or the board dropdown
      const btns = [...document.querySelectorAll('button')];
      const b = btns.find(b => b.textContent.includes('选择一块图板') || b.textContent.includes('Select board') || b.textContent.includes('Abstract Digital Art'));
      if (b) { b.click(); return b.textContent.trim().substring(0, 50); }
      return null;
    });
    if (boardBtn) {
      console.log(`[PIN] Board dropdown clicked: "${boardBtn}"`);
      await humanDelay(1000, 2500);

      const searchInput = await page.waitForSelector('input[placeholder*="搜索" i], input[placeholder*="search" i]', { timeout: 3000 }).catch(() => null);
      if (searchInput) {
        await humanType(page, searchInput, board, { minDelay: 50, maxDelay: 140 });
        await humanDelay(1000, 2000);
      }

      // Click matching board item
      const clicked = await page.evaluate((boardName) => {
        const items = document.querySelectorAll('[role="option"], [role="listbox"] [role="button"], [data-test-id*="board"]');
        for (const item of items) {
          if (item.textContent.includes(boardName)) {
            item.click();
            return item.textContent.trim().substring(0, 50);
          }
        }
        // Fallback: any clickable element with board name
        const all = [...document.querySelectorAll('div, span, button')];
        const match = all.find(el => el.textContent.trim() === boardName && el.offsetParent !== null);
        if (match) { match.click(); return match.textContent.trim(); }
        return null;
      }, board);

      if (clicked) {
        console.log(`[PIN] Board selected: ${clicked}`);
      } else {
        console.log('[PIN] WARN: board not found in dropdown');
      }
      await humanDelay(800, 1500);
    } else {
      console.log('[PIN] WARN: board dropdown not found');
    }

    // 5. Click publish button
    await humanThink(800, 2000);
    const published = await page.evaluate(() => {
      const btns = [...document.querySelectorAll('button')];
      const pub = btns.find(b => {
        const text = b.textContent.trim();
        return (text === '发布' || text === 'Publish') && !b.disabled && b.offsetParent !== null;
      });
      if (pub) {
        pub.click();
        return true;
      }
      return false;
    });

    if (published) {
      console.log('[PIN] Publish button clicked');
    } else {
      console.error('[PIN] ERROR: Publish button not found or disabled');
      await page.screenshot({ path: '/tmp/pinterest-no-publish-btn.png' }).catch(() => {});
      process.exit(1);
    }

    // 6. Wait and verify
    await humanDelay(4000, 7000);

    // Check if we got redirected or if there's a success state
    const url = page.url();
    console.log(`[PIN] Final URL: ${url}`);

    // Check if pin was created by looking at the page
    const result = await page.evaluate(() => {
      // Check for error messages
      const err = document.querySelector('[data-test-id*="error"], [class*="error"]');
      if (err && err.textContent.trim()) return { error: err.textContent.trim().substring(0, 100) };
      return { ok: true };
    });

    if (result.error) {
      console.error(`[PIN] ERROR: ${result.error}`);
      process.exit(1);
    }

    console.log('[PIN] SUCCESS');

  } catch (err) {
    console.error(`[PIN] Error: ${err.message}`);
    await page.screenshot({ path: '/tmp/pinterest-publish-error.png' }).catch(() => {});
    process.exit(1);
  } finally {
    await page.close();
  }
}

main();


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)