> ## Documentation Index
> Fetch the complete documentation index at: https://help.miyaip.com/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Agent 代理接入：Playwright 浏览器会话配置

> 为 AI Agent 的 Playwright 网络工具配置代理，隔离浏览器会话、核验任务前后出口 IP，并处理重试与记录运行结果。

当获准的 Agent 任务需要地域出口、连续的浏览器会话或明确的出站策略时，为它的网络工具配置代理。没有网络工具的纯文本任务无需代理。本教程完成一个三步浏览器任务：读取出口 IP、采集目标页面的标题与 H1、再次读取出口 IP。

## 选择接入与会话方式

HTTP 客户端可直接设置 HTTP、HTTPS 或 SOCKS 代理。浏览器任务可在 Playwright 启动浏览器时设置代理。托管 API、远程浏览器和 MCP 是其他服务可能提供的接入方式，请按所选服务的接口配置；它们不代表 MiyaIP 提供同名产品。

| 接入方式 | 会话控制与仍需配置的内容 |
| - | - |
| 客户端代理设置 | 按凭据、端口或供应商会话键选择线路；你负责客户端生命周期、重试、解析、限速与监控。 |
| 托管抓取 API | 按 API 的请求或会话标识运行；确认渲染行为、目标策略、数据权利与结果质量。 |
| 远程浏览器 | 按浏览器实例或远程 context 隔离；明确 Cookie、任务边界、目标白名单与资源清理。 |
| MCP 工具 | 按 MCP Server 和下游服务的会话机制接入；配置工具权限、输入校验、目标策略、密钥与审计日志。 |

公开 API、数据集或测试任务可先评估数据中心出口。长期固定地区会话可选择静态 ISP 出口；只有任务确实依赖终端用户或蜂窝网络特征时，才选住宅或移动出口。按工作流需要选择代理，而不是按 IP 池规模选择。

| 工作流 | 会话策略 |
| - | - |
| 登录、多步骤表单等有状态任务 | 使用[静态住宅代理](/zh/proxy/static-residential/overview)或所选产品提供的粘性会话；任务内保持同一代理会话与浏览器 context。 |
| 相互独立、无状态的任务 | 可参考[动态住宅代理](/zh/proxy/dynamic-residential/overview)在任务之间轮换；新任务使用新的会话。 |
| 获准任务的恢复 | 在提示词之外保存会话引用和加密浏览器状态，并为二者设置一致的到期时间。 |

在编排层使用稳定的租户、工作流与会话标识选择路由。不要在一次有状态任务进行到一半时更换出口，也不要在不同客户之间共享 Cookie。浏览器 context 保持本地状态；上游代理的固定或粘性设置决定出口是否保持一致，示例会实际比较任务前后的出口。

## 安装并运行 Playwright 示例

<Steps>
  <Step title="安装运行环境">
    在新的工作目录安装 Node.js 项目依赖和 Chromium。

    ```powershell theme={null}
    npm init -y
    npm install playwright
    npx playwright install chromium
    ```

    下载 [ai\_agents.mjs](/examples/ai_agents.mjs) 和 [retry\_after.mjs](/examples/retry_after.mjs)，保存在同一目录。示例使用 Playwright 安装的 Chromium。
  </Step>

  <Step title="配置代理、目标与白名单">
    将以下占位值替换为所提取的代理信息。此示例使用带账号密码的 HTTP 或 HTTPS 代理；服务器地址与认证字段分开设置。

    ```powershell theme={null}
    $env:PROXY_SERVER = 'http://proxy.example.com:1234'
    $env:PROXY_USERNAME = 'YOUR_USERNAME'
    $env:PROXY_PASSWORD = 'YOUR_PASSWORD'
    $env:TARGET_URL = 'https://example.com/'
    $env:ALLOWED_HOSTS = 'example.com'
    ```

    真实凭据由密钥管理工具注入。修改目标时同步修改 `ALLOWED_HOSTS`，多个获准主机使用逗号分隔。脚本还允许 `api.ipify.org`，用于任务前后的出口核验。目标和浏览器请求必须使用白名单中的 HTTPS 主机与默认端口；代理凭据不会写入提示词或日志。
  </Step>

  <Step title="保存完整代码">
    同一次浏览器启动应用一条代理线路，一个 context 承载整个逻辑会话。脚本复用同一个页面执行三步任务，阻止不在白名单中的网络请求，并在结束或失败后关闭 context 和浏览器。

    <CodeGroup>
      ```javascript ai_agents.mjs theme={null}
      import { mkdir, writeFile } from "node:fs/promises";
      import { isIP } from "node:net";
      import { pathToFileURL } from "node:url";
      import { RetryAfterOutOfRange, RetryDeferred, retryDelayMs, waitBeforeRetry } from "./retry_after.mjs";

      export const MAX_ATTEMPTS = 2;
      export const MAX_WAIT_MS = 10_000;
      const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);

      class TaskFailed extends Error {
        constructor(reason) { super(reason); this.reason = reason; }
      }

      function redactIp(value) {
        if (isIP(value) === 4) return value.replace(/\.\d+$/, ".xxx");
        if (isIP(value) === 6) return `${value.split(":").slice(0, 4).join(":")}::`;
        throw new TaskFailed("invalid_exit_ip");
      }

      export async function navigate(page, url, state) {
        for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
          let response;
          try {
            response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
          } catch (error) {
            if (error.name !== "TimeoutError") throw new TaskFailed("navigation_failed");
            if (attempt === MAX_ATTEMPTS - 1) throw new TaskFailed("timeout_attempts_exhausted");
            await waitBeforeRetry(2 ** attempt * 1_000, state.budget,
              (delay) => page.waitForTimeout(delay));
            state.retries += 1;
            continue;
          }
          state.status = response?.status() ?? 0;
          if (state.status === 407) throw new TaskFailed("proxy_authentication_failed");
          if (TRANSIENT_STATUSES.has(state.status)) {
            const delay = retryDelayMs(response.headers()["retry-after"], 2 ** attempt * 1_000);
            if (attempt === MAX_ATTEMPTS - 1) throw new RetryDeferred(delay, "maximum_attempts_reached");
            await waitBeforeRetry(delay, state.budget, (ms) => page.waitForTimeout(ms));
            state.retries += 1;
            continue;
          }
          if (state.status < 200 || state.status >= 400) {
            throw new TaskFailed(`http_status_${state.status}`);
          }
          return response;
        }
        throw new TaskFailed("maximum_attempts_reached");
      }

      export async function runAgent({ chromium, env = process.env, log = (record) => console.log(JSON.stringify(record)) } = {}) {
        const startedAt = performance.now();
        const state = { status: 0, retries: 0, steps: 0, budget: { remainingMs: MAX_WAIT_MS } };
        let browser;
        let context;
        let exitCode = 1;
        try {
          for (const name of ["PROXY_SERVER", "PROXY_USERNAME", "PROXY_PASSWORD", "TARGET_URL"]) {
            if (!env[name]) throw new TaskFailed(`missing_${name}`);
          }
          const proxy = new URL(env.PROXY_SERVER);
          if (!["http:", "https:"].includes(proxy.protocol) || proxy.username || proxy.password) {
            throw new TaskFailed("HTTP_proxy_and_separate_credentials_required");
          }
          const target = new URL(env.TARGET_URL);
          const allowedHosts = new Set((env.ALLOWED_HOSTS || "example.com").split(","));
          allowedHosts.add("api.ipify.org");
          const allowed = (url) => url.protocol === "https:" && allowedHosts.has(url.hostname)
            && ["", "443"].includes(url.port) && !url.username && !url.password;
          if (!allowed(target)) throw new TaskFailed("target_not_allowlisted");
          chromium ??= (await import("playwright")).chromium;
          browser = await chromium.launch({ headless: true, timeout: 30_000, proxy: {
            server: env.PROXY_SERVER, username: env.PROXY_USERNAME, password: env.PROXY_PASSWORD,
          } });
          context = await browser.newContext({ serviceWorkers: "block" });
          await context.route("**/*", async (route) => {
            let permitted = false;
            try { permitted = allowed(new URL(route.request().url())); } catch {}
            if (permitted) await route.continue(); else await route.abort();
          });
          const page = await context.newPage();
          page.setDefaultTimeout(15_000);
          const readExitIp = async () => {
            const response = await navigate(page, "https://api.ipify.org?format=json", state);
            const value = String((await response.json()).ip ?? "");
            redactIp(value);
            state.steps += 1;
            return value;
          };
          const firstExitIp = await readExitIp();
          const targetResponse = await navigate(page, target.href, state);
          const targetStatus = targetResponse.status();
          const result = {
            title: await page.title(),
            heading: (await page.locator("h1").first().textContent() ?? "").trim(),
          };
          if (!result.title || !result.heading) throw new TaskFailed("required_fields_missing");
          result.titleMatchesHeading = result.title === result.heading;
          state.steps += 1;
          const secondExitIp = await readExitIp();
          const sameExitAcrossTask = firstExitIp === secondExitIp;
          const outputDir = env.OUTPUT_DIR || "output";
          await mkdir(outputDir, { recursive: true });
          await writeFile(`${outputDir}/agent-result.json`, JSON.stringify(result, null, 2), "utf8");
          log({ outcome: sameExitAcrossTask ? "success" : "failed", status: targetStatus,
            steps: state.steps, retries: state.retries,
            durationMs: Math.round(performance.now() - startedAt), exitIp: redactIp(firstExitIp),
            sameExitAcrossTask, titleMatchesHeading: result.titleMatchesHeading,
            resultPath: `${outputDir}/agent-result.json` });
          exitCode = sameExitAcrossTask ? 0 : 1;
        } catch (error) {
          const deferred = error instanceof RetryDeferred;
          const reason = error instanceof TaskFailed ? error.reason
            : error instanceof RetryAfterOutOfRange ? "retry_after_out_of_range" : "task_failed";
          log({ outcome: deferred ? "deferred" : "failed", status: state.status,
            steps: state.steps, retries: state.retries,
            durationMs: Math.round(performance.now() - startedAt),
            ...(deferred ? { retry_at: error.retryAt, reason: error.reason } : { reason }) });
          exitCode = deferred ? 2 : 1;
        } finally {
          await context?.close().catch(() => {});
          await browser?.close().catch(() => {});
        }
        return exitCode;
      }

      if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
        process.exitCode = await runAgent();
      }
      ```

      ```javascript retry_after.mjs theme={null}
      export class RetryAfterOutOfRange extends Error {
        constructor() { super("manual_rescheduling_required"); }
      }

      const HTTP_DATE_PATTERNS = [
        /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), ([0-9]{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([0-9]{4}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/,
        /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), ([0-9]{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/,
        /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( [0-9]|[0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) ([0-9]{4})$/,
      ];

      export function retryDelayMs(value, fallbackMs, nowMs = Date.now()) {
        const text = String(value ?? "").trim();
        if (/^[0-9]+$/.test(text)) {
          const seconds = Number(text);
          const maxSeconds = Math.floor((8_640_000_000_000_000 - nowMs) / 1_000);
          if (!Number.isSafeInteger(seconds) || seconds > maxSeconds) {
            throw new RetryAfterOutOfRange();
          }
          return seconds * 1_000;
        }
        const matches = HTTP_DATE_PATTERNS.map((pattern) => pattern.exec(text));
        const format = matches.findIndex(Boolean);
        if (format < 0) return fallbackMs;
        const retryAtMs = Date.parse(text.endsWith(" GMT") ? text : `${text} GMT`);
        if (!Number.isFinite(retryAtMs)) return fallbackMs;
        const fields = matches[format].slice(1);
        const [day, month, year, hour, minute, second] = format === 2
          ? [fields[1], fields[0], fields[5], ...fields.slice(2, 5)] : fields;
        const date = new Date(retryAtMs);
        const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
        const actualYear = format === 1 ? date.getUTCFullYear() % 100 : date.getUTCFullYear();
        // Date.parse can normalize impossible dates such as 31 February.
        if (date.getUTCDate() !== Number(day) || months[date.getUTCMonth()] !== month
          || actualYear !== Number(year) || date.getUTCHours() !== Number(hour)
          || date.getUTCMinutes() !== Number(minute) || date.getUTCSeconds() !== Number(second)) {
          return fallbackMs;
        }
        return Math.max(Math.ceil((retryAtMs - nowMs) / 1_000) * 1_000, 0);
      }

      export class RetryDeferred extends Error {
        constructor(delayMs, reason, nowMs = Date.now()) {
          super("retry_deferred");
          const retryAt = new Date(nowMs + delayMs);
          if (!Number.isFinite(retryAt.getTime())) throw new RetryAfterOutOfRange();
          this.retryAt = retryAt.toISOString();
          this.reason = reason;
        }
      }

      export async function waitBeforeRetry(delayMs, budget, sleep) {
        if (delayMs > budget.remainingMs) {
          throw new RetryDeferred(delayMs, "waiting_budget_exhausted");
        }
        if (delayMs > 0) await sleep(delayMs);
        budget.remainingMs -= delayMs;
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="运行并检查本次结果">
    ```powershell theme={null}
    node ai_agents.mjs
    node --version
    npm list playwright
    ```

    运行后检查控制台的 `outcome`、目标 `status`、`steps`、`retries`、`durationMs`、脱敏 `exitIp` 与 `sameExitAcrossTask`。`output/agent-result.json` 保存本次采集的 `title`、`heading` 与 `titleMatchesHeading`。标题与 H1 不一致本身不代表网络失败，请按目标的数据规则判断。

    完成三步且前后出口一致时退出码为 `0`；出口变化或其他失败为 `1`；任务需要延后执行为 `2`。这里没有预置的成功结果，请以你的实际运行输出为准。
  </Step>
</Steps>

## 重试与任务恢复

每次导航最多尝试 2 次，导航超时为 30 秒，页面操作默认超时为 15 秒，浏览器启动超时为 30 秒。任务内累计重试等待预算为 10 秒。429、500、502、503 和 504 的有效 `Retry-After` 秒数或 HTTP 日期会完整保留，**不会截短为 10 秒**。只有响应头缺失或无效时才使用有限指数退避。

超过剩余等待预算，或最后一次尝试仍返回暂时性错误时，脚本输出 `outcome=deferred`、UTC `retry_at` 与原因，关闭浏览器并退出。编排层必须在 `retry_at` 或之后恢复，示例不创建调度。如果日期超出运行时支持范围，则停止并报告 `retry_after_out_of_range`，需要人工安排。

| 结果 | 处理方式 |
| - | - |
| 407、代理连接或认证失败 | 核对连接字段并停止，不通过轮换 IP 重试错误凭据。 |
| 429 或暂时性服务错误 | 遵守服务器指定的时间；等待超预算时延后任务。 |
| 超时 | 在次数和等待预算内重试；次数耗尽后停止。 |
| 确定性的 4xx 或策略拒绝 | 修复请求或授权，不重试。 |
| 浏览器或解析错误 | 记录脱敏的错误类别和失败步骤；只有状态能够安全重建时才重启 context。 |
| 目标不在白名单或字段缺失 | 停止并核对任务配置、页面结构与选择器。 |
| 前后出口不同 | 运行结果记为失败；核对固定或粘性会话配置。 |

## 接入 Agent 编排层

由已批准的 HTTP 或浏览器工具执行请求。编排层校验目标、租户、任务和路由键，网关再选择上游出口。凭据仅在运行时注入，模型只接收所需结果与脱敏状态。

生产系统还需在网络层处理 DNS 重绑定、私有或元数据地址、短期凭据、并发与带宽限制。为写操作保留幂等键，按出口健康状态设置熔断，并隔离租户与浏览器存储。这些能力需要在你的基础设施中配置，单文件示例只演示浏览器请求和会话流程。

记录时间、任务标识、哈希会话标识、主机名、步骤、尝试次数、状态类别和耗时。不要记录代理密码、Cookie、Authorization 请求头、原始异常或带密钥的完整 URL。先用受控响应测试认证失败、限流、超时与延后执行，再逐步增加任务量。页面采集与文件导出还可参考[网页抓取教程](/zh/developers/web-scraping)。

浏览器代理配置见 [Playwright 官方文档](https://playwright.dev/docs/network#http-proxy)，重试时间格式见 [RFC 9110：Retry-After](https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after)。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.