# 不给 AI 登录信息的智能体浏览器自动化

**Summary:** 一篇面向初学者的教程：用 Selenium、noVNC 手动登录和受限 API 封装，构建按计划或队列运行的智能体浏览器自动化，让智能体在看不到凭证、也不控制你个人电脑的情况下创建网站表单草稿。

- Canonical: https://markhuang.ai/zh/blog/ai-browser-automation-without-sharing-login
- Language: zh-CN
- Author: [Mark Huang](https://markhuang.ai/about)
- Published: 2026-06-26
- Section: 教程
- Tags: 浏览器自动化, Selenium, Docker, AI 智能体, API 封装, 教程
- License: https://creativecommons.org/licenses/by-nc/4.0/

---

![人类保留登录钥匙，AI 只把结构化请求发送给受保护的浏览器容器](https://cdn.markhuang.ai/blog/ai-browser-automation-without-sharing-login/hero.webp)

*核心思路就一句话：登录归人，数据归 AI，浏览器归隔离容器。*

## 太长不看版

如果你只是偶尔要让 AI 帮忙查点东西，直接用 ChatGPT、Codex、Claude Desktop 自带的浏览器就够了，不用往下看了。

这篇教程要解决的是另一回事：**需要定时跑、按队列跑的 Agent 自动化**。比如每天自动起草表单、定期刷新某个门户、处理内部后台的重复劳动，或者合上笔记本之后还要在服务器上继续跑的工作流。

```text
定时器 / 队列
  -> Agent 工作流
  -> 你写的一层薄 Wrapper API
  -> Selenium WebDriver
  -> 浏览器容器
  -> 已登录的网站
```

你自己通过 noVNC 打开一个看得见的浏览器去登录。AI 只能向 Wrapper 发结构化请求，比如"帮我填这份草稿"、"截个图给我看看"。登录凭证不会进 AI 对话，也不用让 Computer-Use Agent 直接操作你的电脑。

一句话总结适用场景：网站要登录、没有公开 API、但你又希望 Agent 跑在小服务器上，而不是靠你的笔记本 24 小时开机。

## 到底要解决什么问题

很多真正有用的流程，都卡在网页背后。

合作方门户、内部后台、学校表单、会员站点，或者某个只能在浏览器里点的老系统——你明明知道 AI 可以帮你准备数据、填好表单，但网站就是没 API。

最容易想到的捷径，往往最危险：

```text
把账号密码给 AI
让 AI 打开网站
让 AI 替我操作浏览器
```

这么干等于把一堆职责搅在一起：AI 看到了密码，AI 可以乱点一通，你的笔记本也被拖进了生产流程。万一这个任务要半夜跑、要出差时也跑，难道你的个人电脑要 24 小时开机？

更合理的分工是这样：

```text
人负责登录。
浏览器容器负责会话。
Wrapper API 负责哪些动作可以放行。
Agent 负责循环执行的业务流程。
```

这篇教程要搭的，就是这套架构。

## 那直接用 ChatGPT 的浏览器不行吗？

AI 自带的浏览器，给交互式任务用挺顺手：你人在旁边，问一句，它打开页面、读内容、点按钮、把结果汇报给你。

但 Agent 自动化是另一种形态。它可能每天早上 8 点自己起来干活，可能从队列里一条条取任务，可能在网站超时时自己重试，可能在你睡觉时把草稿填好，只在会话掉线需要重新登录时才叫你。

| 场景               | AI 自带浏览器    | Wrapper API + Selenium |
| ---------------- | ----------- | ---------------------- |
| 临时查个资料、浏览一下      | 正合适         | 杀鸡用牛刀                  |
| 每天跑、或者事件触发的自动化   | 撑不住         | 正合适                    |
| 电脑关机后还要继续跑       | 做不到         | 可以，跑在服务器上              |
| 给 Agent 用的稳定工具接口 | 有限          | 明确的 HTTP API           |
| 登录怎么处理           | 容易和聊天内容混在一起 | 人通过 noVNC 自己登          |

这里不是说自带浏览器不好——它解决交互场景，这套模式解决自动化场景，各管一摊。

## 拿一个每日自动化场景举例

假设你有一个没有 API 的供应商门户。每个工作日早上，你想让 Agent 根据昨天的运营笔记，自动起草一批请求。

```text
早上 8 点定时触发
  -> 从数据库或邮箱拉出昨天的运营笔记
  -> AI 写好请求的标题和正文
  -> Agent 用 dry_run=true 调用 POST /portal/draft-request
  -> Wrapper 在已登录的浏览器里填好表单
  -> Wrapper 返回截图和状态
  -> 如果需要人工审核、重新登录或批准提交，Agent 通知你
```

这跟"让聊天工具临时帮我浏览一下"完全不是一回事。Agent 有固定的任务、有重试路径、有明确的工具接口，也有"该停下来等人"的刹车机制。

第一版建议把最后一步留给人。让 Agent 起草，让 Wrapper 返回截图，只要动作有风险，最终点"提交"的还是人。

## 你要搭什么东西

教程里用的目标网站，是一个有"新建请求"表单的私有供应商门户。表单包含 `subject`、`details`、`priority`，以及一个"保存草稿"按钮。你实际的网站肯定不一样，但架构是通用的。

| 组件          | 干什么                            |
| ----------- | ------------------------------ |
| 定时器 / 队列    | 每天定时或来任务时启动自动化                 |
| Agent 工作流   | 准备数据、调用工具、处理重试和审核状态            |
| Wrapper API | 把安全的 HTTP 请求翻译成 Selenium 动作    |
| Selenium 容器 | 跑 Chrome，在 `4444` 暴露 WebDriver |
| noVNC       | 让你通过 `7900` 看到并操作浏览器           |

官方的 [Docker Selenium README](https://github.com/SeleniumHQ/docker-selenium/blob/trunk/README.md) 里写清楚了独立浏览器镜像、WebDriver 端口、noVNC 端口，以及常见的 `--shm-size 2g` 配置。下面我们要做的，是在这层浏览器外面再包一个更小的 Wrapper，让 AI 不直接跟 WebDriver 打交道。

## 你需要准备什么

- Docker 和 Docker Compose
- 一个你有权拿来做自动化的网站账号
- 基本的终端操作经验
- 一个能发 HTTP 请求的 Agent 运行环境，比如 n8n、cron + worker，或者任意 Agent 框架

> **Info:**
>
> 动手之前先看一下网站的条款和频率限制。这套模式是给合法的个人 / 内部流程用的，不是用来绕过访问控制、抓私有数据、或者跟反爬系统对着干的。

## 第一步：起一个看得见的浏览器容器

先建个目录：

```bash
mkdir browser-automation-wrapper
cd browser-automation-wrapper
```

新建 `docker-compose.yml`：

```yaml
services:
  selenium:
    image: selenium/standalone-chrome:latest
    shm_size: "2g"
    ports:
      - "127.0.0.1:4444:4444"
      - "127.0.0.1:7900:7900"
    environment:
      SE_VNC_PASSWORD: "change-this-password"
```

拉起来：

```bash
docker compose up -d
```

打开实时浏览器：

```text
http://127.0.0.1:7900/?autoconnect=1&resize=scale&password=change-this-password
```

用这个浏览器**自己**登录目标网站。不要让 AI 帮你登，更不要把密码粘到 AI 对话框里。从这一刻起，浏览器会话就活在容器里了。

## 第二步：把 WebDriver 藏好

WebDriver 权限非常大——谁能访问它，谁就能完全控制浏览器。这就是为什么 compose 文件里要把 `4444` 和 `7900` 都绑到 `127.0.0.1`。

![受保护的 API 网关只放行批准的自动化卡片，阻挡任意浏览器命令](https://cdn.markhuang.ai/blog/ai-browser-automation-without-sharing-login/api-boundary.webp)

*Wrapper 就是那道闸门：白名单内的动作放行，任意浏览器控制一律挡住。*

| 要做                     | 别做                      |
| ---------------------- | ----------------------- |
| Selenium 绑定到 localhost | 把 `4444` 直接暴露在公网        |
| noVNC 只用于手动登录和调试       | 把 noVNC 权限交给 Agent      |
| 只暴露一个很窄的 Wrapper API   | 让 AI 直接发任意 WebDriver 命令 |

这是整套方案最关键的安全边界。AI 不应该拿到一个"通用浏览器控制"端点，它应该只有一个小小的 API，里面装的都是你审核过的动作。

## 第三步：先把允许的流程写清楚

写代码之前，先把任务用文字描述清楚。拿示例门户来说：

```text
允许的动作：
  在供应商门户里创建一份请求草稿。
​
输入：
  subject
  details
  priority
  dry_run
​
规则：
  绝不登录。
  绝不动账号设置。
  dry_run 不是 false 时，绝不提交最终表单。
  如果浏览器停在登录页，返回 needs_login。
  返回截图路径，方便调试。
```

这正是 Wrapper 比 Computer-Use Agent 更安全的地方：Agent 没法临时起意去点"账号设置"，因为你的 API 里压根没这个动作。

## 第四步：让 AI 帮你生成 Wrapper

下面这段 prompt 可以直接丢给你的编程 AI。用之前先打开目标页面看一下，把里面假的门户 URL 和选择器名换成你真实网站的信息。

```text
构建一个小型 FastAPI 应用，用来封装 Selenium Remote WebDriver。
​
目标：
做一个很窄的 API，用来填写一个已经登录的网站表单。网站本身没有 API。
用户会通过 noVNC 手动登录。应用严禁直接处理用户名、密码、2FA 验证码或 cookies。
​
技术栈：
- Python 3.12
- FastAPI
- Selenium 的 Python 包
- Remote WebDriver 地址来自 SELENIUM_URL，默认 http://selenium:4444/wd/hub
​
端点：
- GET /health
- POST /portal/draft-request
- GET /debug/screenshot
​
安全要求：
- 除 /health 外，所有端点都必须校验 Authorization: Bearer $AUTOMATION_API_TOKEN。
- 不接受调用方传入的任意 CSS 选择器、URL 或 JavaScript。
- 选择器统一放在服务端的 SELECTORS 字典里。
- 如果浏览器在登录页，返回 {"status": "needs_login"}。
- dry_run 默认 true。dry_run 模式下只填字段，不点"保存草稿"。
- 记录动作日志，但绝不记录敏感信息或完整 cookies。
​
草稿请求的输入：
- subject: 字符串，最多 120 字符
- details: 字符串，最多 4000 字符
- priority: low / normal / high 之一
- dry_run: 布尔值，默认 true
​
Selenium 行为：
- 应用运行期间复用一个浏览器会话。
- 用 WebDriverWait 和 expected_conditions，不要盲目 sleep。
- 导航到 https://example-portal.invalid/requests/new。
- 填写 subject、details、priority。
- 只有 dry_run 为 false 时才点击"保存草稿"。
- 返回 status、current_url、page_title、screenshot_file。
​
部署要求：
- 提供 Dockerfile 和 docker-compose.yml。
- docker-compose.yml 里把 Wrapper API 绑到 127.0.0.1:8088，方便本地调试。
- Selenium 和 noVNC 不能对外暴露。
- 只在意图不明显的地方加注释，别灌水。
```

这段 prompt 本身比用什么框架更重要。它把边界讲清楚了：不碰登录、不接受任意浏览器控制、不公开 WebDriver。

## 第五步：一个最小的 Wrapper 示例

按上面的 prompt 生成的 Wrapper，大概长这样。新建 `form-wrapper/main.py`：

```python
import os
from typing import Literal
​
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import Select, WebDriverWait
​
SELENIUM_URL = os.getenv("SELENIUM_URL", "http://selenium:4444/wd/hub")
API_TOKEN = os.environ["AUTOMATION_API_TOKEN"]
PORTAL_DRAFT_URL = "https://example-portal.invalid/requests/new"
​
SELECTORS = {
    "subject": "#request_subject",
    "details": "#request_details",
    "priority": "#request_priority",
    "save_draft": "button[data-action='save-draft']",
}
​
app = FastAPI()
driver = None
​
​
class DraftRequest(BaseModel):
    subject: str = Field(max_length=120)
    details: str = Field(max_length=4000)
    priority: Literal["low", "normal", "high"] = "normal"
    dry_run: bool = True
​
​
def require_token(authorization: str | None) -> None:
    if authorization != f"Bearer {API_TOKEN}":
        raise HTTPException(status_code=401, detail="Unauthorized")
​
​
def browser():
    global driver
    if driver is None:
        options = webdriver.ChromeOptions()
        driver = webdriver.Remote(command_executor=SELENIUM_URL, options=options)
    return driver
​
​
def on_login_page(current_url: str, title: str) -> bool:
    text = f"{current_url} {title}".lower()
    return "login" in text or "sign-in" in text or "signin" in text
​
​
@app.get("/health")
def health():
    return {"status": "ok"}
​
​
@app.post("/portal/draft-request")
def draft_request(payload: DraftRequest, authorization: str | None = Header(default=None)):
    require_token(authorization)
​
    page = browser()
    wait = WebDriverWait(page, 20)
    page.get(PORTAL_DRAFT_URL)
​
    if on_login_page(page.current_url, page.title):
        return {"status": "needs_login", "current_url": page.current_url}
​
    wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, SELECTORS["subject"]))).clear()
    page.find_element(By.CSS_SELECTOR, SELECTORS["subject"]).send_keys(payload.subject)
​
    page.find_element(By.CSS_SELECTOR, SELECTORS["details"]).clear()
    page.find_element(By.CSS_SELECTOR, SELECTORS["details"]).send_keys(payload.details)
​
    Select(page.find_element(By.CSS_SELECTOR, SELECTORS["priority"])).select_by_value(payload.priority)
​
    if not payload.dry_run:
        wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, SELECTORS["save_draft"]))).click()
​
    screenshot_file = "/tmp/latest-screenshot.png"
    page.save_screenshot(screenshot_file)
​
    return {
        "status": "filled" if payload.dry_run else "draft_saved",
        "current_url": page.current_url,
        "page_title": page.title,
        "screenshot_file": screenshot_file,
    }
​
​
@app.get("/debug/screenshot")
def screenshot(authorization: str | None = Header(default=None)):
    require_token(authorization)
    page = browser()
    screenshot_file = "/tmp/latest-screenshot.png"
    page.save_screenshot(screenshot_file)
    return {"status": "ok", "screenshot_file": screenshot_file}
```

这个示例故意做得很窄：不能浏览任意 URL，不能执行任意 JavaScript，也不能填 AI 临时指定的选择器。选择器要你自己看过目标页面之后，在服务端改。

新建 `form-wrapper/Dockerfile`：

```dockerfile
FROM python:3.12-slim
​
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn selenium pydantic
​
COPY main.py /app/main.py
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8088"]
```

更新 `docker-compose.yml`：

```yaml
services:
  selenium:
    image: selenium/standalone-chrome:latest
    shm_size: "2g"
    ports:
      - "127.0.0.1:4444:4444"
      - "127.0.0.1:7900:7900"
    environment:
      SE_VNC_PASSWORD: "change-this-password"
​
  form-wrapper:
    build: ./form-wrapper
    depends_on:
      - selenium
    ports:
      - "127.0.0.1:8088:8088"
    environment:
      SELENIUM_URL: "http://selenium:4444/wd/hub"
      AUTOMATION_API_TOKEN: "${AUTOMATION_API_TOKEN}"
```

新建 `.env`：

```bash
AUTOMATION_API_TOKEN=replace-this-with-a-long-random-token
```

带上 Wrapper 一起重启：

```bash
docker compose up -d --build
```

## 第六步：先别让 AI 上场，自己测一遍

先把 token 加载到终端：

```bash
set -a
. ./.env
set +a
```

调一下 Wrapper：

```bash
curl -X POST http://127.0.0.1:8088/portal/draft-request \
  -H "Authorization: Bearer $AUTOMATION_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "季度供应商沟通",
    "details": "请为下一次供应商评审准备一份请求草稿。",
    "priority": "normal",
    "dry_run": true
  }'
```

如果返回 `needs_login`，就打开 noVNC 自己登一次，再重发同一个请求。

在你亲眼看到浏览器把字段填对之前，`dry_run` 一直开着。确认没问题了，再允许 Wrapper 去点"保存草稿"。对敏感流程来说，最终那个"提交"按钮最好永远留给人。

## 第七步：让 Agent 来调 Wrapper

Wrapper 手动跑通之后，Agent 工作流就可以像调普通 HTTP API 一样调它了。这个工作流可以是 n8n、cron job、一个小 worker、队列消费者，或者任何一个支持 HTTP 工具的 Agent 框架。

```text
你可以通过这个 API 创建请求草稿：
​
POST http://127.0.0.1:8088/portal/draft-request
Authorization: Bearer $AUTOMATION_API_TOKEN
Content-Type: application/json
​
规则：
- 永远不要问我要网站密码。
- 永远不要问我要 2FA 验证码。
- 永远不要直接调 Selenium 或 noVNC。
- 如果 API 返回 needs_login，告诉我通过 noVNC 登录。
- 除非我明确批准保存草稿，否则一律用 dry_run=true。
```

分工就清楚了：AI 负责语言和结构，Wrapper 负责浏览器动作，已登录的浏览器负责跟网站打交道。边界一清楚，整个流程就好理解、好排查了。

这里最关键的是 Agent 的"刹车机制"。如果 Wrapper 返回 `needs_login`，Agent 不应该试图通过问你要凭证来自己解决登录问题——它应该通知你、暂停任务，等你在 noVNC 上登完再继续。

## 第八步：搬到服务器上

![开发者通过私有隧道查看运行在小型远程服务器上的浏览器容器](https://cdn.markhuang.ai/blog/ai-browser-automation-without-sharing-login/server-runner.webp)

*做 24 小时工作流时，浏览器跑在服务器上；你的笔记本只用来登录和调试。*

本地是最稳妥的第一步。真要 24/7 跑，就把同一套 Docker Compose 搬到一台小 VPS 上。

| 本地                       | 服务器                                   |
| ------------------------ | ------------------------------------- |
| 直接访问 `127.0.0.1:7900`    | 通过 SSH 隧道访问 noVNC                     |
| Wrapper API 绑在 localhost | 通过 HTTPS / VPN / Tailscale 暴露 Wrapper |
| 笔记本得一直开着                 | VPS 保持浏览器会话常驻                         |

在远程服务器上，千万别把 noVNC 直接暴露到公网。需要登录或调试时，用隧道连过去就行：

```bash
ssh -L 7900:127.0.0.1:7900 user@your-server
```

然后在自己电脑上打开：

```text
http://127.0.0.1:7900/?autoconnect=1&resize=scale&password=change-this-password
```

如果你的 AI 平台需要从服务器外部调 Wrapper，那就只暴露 Wrapper 一个端口，用 HTTPS + 强 bearer token 保护起来，再配合 IP 白名单或私有网络。`4444` 和 `7900` 继续藏着别动。

## 会话持久化怎么办

入门版的做法是：只要 Selenium 容器不挂，浏览器会话就一直活着。对第一个工作流来说，这通常够用了。

如果你希望容器重启后登录态还在，可以后面再挂一个 Chrome profile 卷，但要小心测试。有些网站会让 cookie 过期，会要求重新做 2FA，会在 IP 变了之后直接作废会话。Chrome profile 在浏览器非正常退出时还可能留下锁文件，重启时挺烦的。

建议先守住这条简单规则：

```text
容器保持运行。
会话过期就返回 needs_login。
让人通过 noVNC 重新登录。
```

## 几条值得守住的护栏

| 风险          | 护栏                                   |
| ----------- | ------------------------------------ |
| AI 看到登录凭证   | 人通过 noVNC 登录，Wrapper 永远不接收密码         |
| AI 点错地方     | Wrapper 只暴露白名单动作，不给完整浏览器控制           |
| 网站改版        | 用明确的选择器、截图和 `needs_review` 响应        |
| 自动化提交了脏数据   | 默认 `dry_run`，先存草稿再最终提交               |
| 远程控制端点暴露在公网 | WebDriver 和 noVNC 只绑 localhost 或私有网络 |

## 什么时候不该用这套

如果网站有正经 API，那就用 API，别折腾这套。API 更稳、更好监控，也不会因为页面一改就挂。

如果网站明确禁止自动化，或者用 CAPTCHA 画了一条清晰的反自动化红线，又或者某个操作点错一次代价很高，也别硬上。这种情况下，让 AI 准备数据就好，浏览器里的关键动作还是得人来做。

## 真正有用的是那条边界

这套方案的重点不是 Selenium——Selenium 随时可以换掉。真正有用的是那条边界。

```text
AI 不该拥有你的登录。
AI 不该拥有你的整个浏览器。
AI 可以拥有结构化的草稿数据。
你的 API 决定哪些浏览器动作可以放行。
```

这是自动化无 API 网站时一种更稳的做法。你照样能拿到 AI 起草的内容、24/7 的服务器端执行、看得见的浏览器调试——但不必把密码交给 Agent，也不必让它操作你的个人电脑。
