指纹浏览器 API 自动化接入指南:启动 Profile、获取连接端点并连接 Playwright

2 阅读12分钟

很多浏览器自动化脚本都是从打开网页开始:访问地址、填写表单、点击按钮,再读取页面结果。

换成指纹浏览器后,页面操作本身没有发生太大变化,但脚本前面多了一层浏览器环境管理。这个环境通常已经保存了账号登录状态、代理配置、浏览器指纹、Cookie 和本地数据,不能再把它当成每次临时创建的空白浏览器。

假设一个账号已经在某个浏览器环境中完成登录,并绑定了对应代理。自动化任务需要继续使用这个账号时,理想流程不是重新创建浏览器、重新配置代理和再次登录,而是启动原来的环境,再让脚本接手其中的网页操作。

整个接入过程可以先理解为两步:

  1. 指纹浏览器启动指定的浏览器环境;
  2. Playwright 连接已经运行的浏览器并操作页面。

API 负责启动和管理环境。浏览器启动后,产品会根据自身实现提供调试端口、CDP 地址或 WebSocket Endpoint。Playwright 获得这个连接端点后,才能进入对应环境执行页面任务。

API、CDP 和 Playwright 分别负责什么

api-cdp-playwright-responsibility-map-2.png 普通 Playwright 脚本通常从下面这行代码开始:

const browser = await chromium.launch();

此时浏览器由 Playwright 创建,使用的是脚本提供的启动参数和 Browser Context 配置。

指纹浏览器 API 自动化操作的却不是一个临时 Chromium,而是已经保存了代理、指纹参数、Cookie 和站点数据的浏览器环境,也就是 Profile。

一条基础接入链路通常包括:

  1. 调用产品 API 启动指定 Profile;
  2. 等待浏览器进程和调试端点就绪;
  3. 获取 CDP 地址或 WebSocket Endpoint;
  4. 使用 Playwright 连接运行中的 Chromium;
  5. 读取 Profile 原有的 Browser Context;
  6. 在这个 Context 中创建页面并执行任务;
  7. 关闭当前任务创建的业务页面;
  8. 断开 Playwright 连接;
  9. 调用产品 API 停止由当前任务启动的 Profile。

三者控制的是不同层次:

控制层主要职责不负责什么
指纹浏览器 API创建、查询、启动和停止 Profile不负责定位或点击网页元素
CDP为运行中的 Chromium 提供调试连接不负责管理 Profile 和编排任务
Playwright打开页面、点击、输入、等待和读取结果不负责保存或重新生成账号环境

Playwright 的 connectOverCDP() 可以连接已经运行的 Chromium,并访问浏览器中的默认 Context。

CDP 连接只适用于 Chromium,而且提供的能力可能少于 Playwright 原生协议。任务依赖 Firefox 或特定 Playwright 高级能力时,需要先确认指纹浏览器实际提供的连接方式。

最小接入链路

以下代码按接入步骤拆分展示。实际使用时,需要将它们放入同一模块,并补充具体产品的 API 请求、鉴权和响应解析。

先忽略重试和异常处理,基础代码可以写成:

const startResult = await adapter.start(profileId);

if (!startResult.startRequestAccepted) {
  throw new Error('产品 API 没有接受 Profile 启动请求');
}

if (!startResult.startedByCurrentTask) {
  throw new Error(
    'Profile 已经在运行,基础接入示例不接管已有实例',
  );
}

const endpoint = await adapter.getEndpoint(
  profileId,
  startResult,
);

const browser = await chromium.connectOverCDP(endpoint);

const [context] = browser.contexts();

if (!context) {
  throw new Error(
    '已经连接浏览器,但没有找到默认 Browser Context',
  );
}

const page = await context.newPage();

这里的 adapter 代表具体产品的 API 适配层。

不同指纹浏览器可能返回:

  • 本地调试端口;
  • HTTP CDP 地址;
  • WebSocket Endpoint;
  • 需要二次查询才能取得的连接地址。

业务代码不必直接处理这些产品差异。接口路径、鉴权方式和响应字段可以封装在适配器中,让页面任务只接收最终可用的 Endpoint。

代码直接在默认 Context 中创建页面:

const page = await context.newPage();

这样不会误操作产品启动页、扩展页面或上一次任务遗留的标签页。

只有明确需要接管某个已经打开的页面时,才根据 URL 或其他条件筛选:

const page =
  context.pages().find((item) =>
    item.url().includes('example.com'),
  ) ?? (await context.newPage());

如果接管的是原本已经存在的页面,任务结束时不能默认将其关闭。只有脚本通过 context.newPage() 创建的页面,才应由脚本负责清理。

profile-endpoint-playwright-connection-flow-3.png

用适配器隔离不同产品的 API

下面是一套厂商无关的 TypeScript 接口。它不对应任何产品的真实 API 路径,具体请求方式和字段名称需要按照产品文档实现。

startedByCurrentTask 是业务适配器根据产品响应和启动前状态归一化得到的字段,不代表所有指纹浏览器原生 API 都会直接返回该字段。

type StartProfileResult = {
  /**
   * 产品是否明确接受了启动请求。
   * 不代表此时连接端点已经可以使用。
   */
  startRequestAccepted: boolean;

  /**
   * 是否能够确认 Profile 是由当前任务启动的。
   *
   * Profile 原本已经运行、接口没有创建新实例,
   * 或启动结果无法确认时,都不应返回 true。
   */
  startedByCurrentTask: boolean;

  /**
   * 部分产品会在启动响应中直接返回连接端点。
   */
  endpoint?: string;
};

interface BrowserProfileAdapter {
  /**
   * 启动指定 Profile。
   */
  start(profileId: string): Promise<StartProfileResult>;

  /**
   * 解析或查询可供 Playwright 连接的端点。
   */
  getEndpoint(
    profileId: string,
    startResult: StartProfileResult,
  ): Promise<string>;

  /**
   * 停止当前任务明确启动的 Profile。
   * 最好能够安全处理重复调用或已停止状态。
   */
  stop(profileId: string): Promise<void>;
}

适配器内部可以处理:

  • API Key 或 Token;
  • 本地客户端地址;
  • Profile ID 字段;
  • Headless 启动参数;
  • 接口返回的调试端口;
  • HTTP 地址和 WebSocket 地址转换;
  • Profile 状态查询;
  • 产品版本和套餐限制。

以 Web4 Browser 为例,自动化任务可以先准备一个能够重复打开的独立 Profile,用于保存账号对应的指纹参数、代理、Cookie 和本地数据。分组、标签和模板负责整理环境,具体隔离方式可以参考浏览器指纹环境管理

环境创建、代理接入和本地数据隔离可以先单独验证。程序化接入是否开放,则需要以当前客户端版本和套餐范围为准,不能因为基础 Profile 可以正常使用,就推断所有版本都默认提供 CDP 或自动化接口。

如果适配器使用 Node.js 原生 fetch 请求产品 API,需要 Node.js 18 或更高版本;使用其他 HTTP 客户端时,以对应依赖的运行要求为准。

Endpoint 已返回但无法连接时执行有限重试

部分产品采用异步启动流程。启动接口返回 Endpoint 后,Chromium 进程或调试端口仍可能处于初始化状态。

此时立即连接可能遇到:

ECONNREFUSED

或者:

WebSocket connection failed

可以为 CDP 连接增加有限重试:

import { chromium, type Browser } from 'playwright';
import { setTimeout as delay } from 'node:timers/promises';

async function connectWithRetry(
  endpoint: string,
  attempts = 10,
): Promise<Browser> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await chromium.connectOverCDP(endpoint, {
        timeout: 5_000,
      });
    } catch (error) {
      lastError = error;

      if (attempt < attempts) {
        await delay(1_000);
      }
    }
  }

  throw new Error(
    `Playwright 无法连接 CDP:${String(lastError)}`,
  );
}

重试必须设置上限。无限等待不仅会占用任务资源,也可能掩盖浏览器进程已经异常退出的问题。

Endpoint 获取失败和 CDP 连接失败也应分开记录:

PROFILE_START_FAILED
PROFILE_START_RESULT_UNKNOWN
CDP_ENDPOINT_TIMEOUT
CDP_CONNECT_FAILED
PAGE_NAVIGATION_FAILED
PAGE_CLOSE_FAILED
PROFILE_STOP_FAILED

这样才能判断应该检查产品 API、浏览器进程、网络地址,还是 Playwright 页面代码。

完整代码要覆盖页面、连接和 Profile 回收

下面将启动、Endpoint 获取、Playwright 连接、页面任务和资源清理放进同一条基础流程。

import {
  type Browser,
  type Page,
} from 'playwright';

async function runProfileTask(
  adapter: BrowserProfileAdapter,
  profileId: string,
  targetUrl: string,
): Promise<void> {
  let startResultKnown = false;
  let profileStartedByCurrentTask = false;

  let browser: Browser | undefined;
  let page: Page | undefined;

  try {
    let startResult: StartProfileResult;

    try {
      startResult = await adapter.start(profileId);
      startResultKnown = true;
    } catch (error) {
      /*
       * 请求可能尚未到达服务端,也可能已经启动 Profile,
       * 只是客户端没有收到响应。
       *
       * 结果未知时记录异常,但不要贸然关闭环境。
       */
      console.error('PROFILE_START_RESULT_UNKNOWN', {
        profileId,
        error,
      });

      throw error;
    }

    if (!startResult.startRequestAccepted) {
      throw new Error('产品 API 没有接受 Profile 启动请求');
    }

    /*
     * 基础接入示例只操作由当前任务启动的 Profile。
     * 已经运行的实例可能正在被人工或其他任务使用,
     * 因此不在这里自动接管。
     */
    if (!startResult.startedByCurrentTask) {
      throw new Error(
        'Profile 已经在运行,基础接入示例不接管已有实例',
      );
    }

    profileStartedByCurrentTask = true;

    const endpoint = await adapter.getEndpoint(
      profileId,
      startResult,
    );

    browser = await connectWithRetry(endpoint);

    const [context] = browser.contexts();

    if (!context) {
      throw new Error(
        '已经连接浏览器,但没有找到默认 Browser Context',
      );
    }

    /*
     * 当前业务页面由本任务创建,
     * 因此应在 finally 中由本任务负责关闭。
     */
    page = await context.newPage();

    await page.goto(targetUrl, {
      waitUntil: 'domcontentloaded',
      timeout: 30_000,
    });

    console.log({
      profileId,
      title: await page.title(),
      url: page.url(),
    });
  } finally {
    /*
     * 只关闭当前任务创建的业务标签页,
     * 不关闭 Profile 的默认 Browser Context。
     */
    if (page) {
      await page.close().catch((error) => {
        console.error('PAGE_CLOSE_FAILED', {
          profileId,
          error,
        });
      });
    }

    /*
     * 对通过 connectOverCDP() 获得的 Browser,
     * browser.close() 用于清理当前连接创建的资源,
     * 并断开 Playwright 与浏览器之间的连接。
     *
     * Profile 是否继续运行,仍由指纹浏览器产品的
     * API 和生命周期规则控制。
     */
    if (browser) {
      await browser.close().catch((error) => {
        console.error(
          '断开 Playwright 连接失败:',
          error,
        );
      });
    }

    /*
     * 只有启动结果明确,并且能够确认 Profile
     * 是由当前任务启动时,才执行停止操作。
     */
    if (
      startResultKnown &&
      profileStartedByCurrentTask
    ) {
      await adapter.stop(profileId).catch((error) => {
        console.error(
          'Profile 回收失败,需要人工检查:',
          {
            profileId,
            error,
          },
        );
      });
    }
  }
}

这段代码区分了三种结果:

  • 明确由当前任务启动的 Profile,在任务结束后回收;
  • Profile 原本已经运行,基础示例拒绝接管,也不会将其停止;
  • 启动请求抛出异常且服务端结果未知时,只记录 PROFILE_START_RESULT_UNKNOWN,不贸然关闭环境。

它还单独清理了当前任务创建的业务页面。

确实需要接管一个已经运行的 Profile 时,应先确认它没有被人工或其他任务占用,并为页面归属和环境停止建立独立规则。这属于进阶接入场景,不是基础代码的默认行为。

profile-task-cleanup-lifecycle-4.png

复用登录状态时不要创建新的 Context

连接现有 Profile 后,一个常见错误是调用:

const context = await browser.newContext();

browser.newContext() 创建的是新的隔离会话。

如果任务需要复用 Profile 中已经保存的 Cookie、Local Storage 和登录状态,新 Context 通常无法继承这些数据。最终表现就是连接了正确的 Profile,网站打开后却仍然处于未登录状态。

需要复用环境状态时,应读取默认 Context:

const [context] = browser.contexts();

if (!context) {
  throw new Error('没有找到目标 Profile 的默认 Context');
}

随后在这个 Context 中创建新页面:

const page = await context.newPage();

这里创建的是新标签页,不是新的 Browser Context,因此仍然属于目标 Profile 原有的会话环境。

新建 Context 本身并不是错误。只有在任务明确需要继承 Profile 状态时,创建新的隔离 Context 才会破坏预期。

本地 API、远程连接和 Headless 的区别

本地 API 通常由桌面客户端或本地服务提供,适合本地开发、可视化调试和人工接管。接入前需要确认客户端是否必须保持运行、API 服务是否开启,以及程序接口是否受版本或套餐限制。

远程连接 由另一台设备或云端服务启动浏览器,脚本通过远程 CDP 或 WebSocket Endpoint 接入。除了 API 本身,还需要处理网络可达性、鉴权、会话超时和 Endpoint 泄露风险。

Headless 只表示浏览器不显示常规窗口,不会自动解决任务调度、异常重试和 Profile 回收。更稳妥的方式是先在可视化模式下跑通完整接入链路,再验证 Headless 模式下的页面行为和环境状态。

常见接入失败应该先查哪里

API 返回 401 或 403

优先检查:

  • Token 或 API Key;
  • 请求头格式;
  • Profile 所属工作区;
  • 程序接口是否受版本或套餐限制。

鉴权失败发生在环境管理层,不需要继续尝试 CDP 连接。

Profile 已经在运行

基础示例不自动接管已经运行的实例。

先确认环境是否正在被人工或其他任务使用,再决定继续连接、等待还是退出。不能因为取得了 Endpoint,就默认拥有该 Profile 的操作权和停止权。

API 成功,但 Playwright 无法连接

按下面的顺序检查:

  1. 是否已经获得 Endpoint;
  2. 返回的是端口、HTTP 地址还是 WebSocket 地址;
  3. Chromium 和调试端口是否已经就绪;
  4. Endpoint 能否从脚本所在设备访问;
  5. 本地客户端或远程运行节点是否仍然在线。

连接成功,但登录状态没有保留

重点检查:

  • 是否误用了 chromium.launch()
  • 是否调用了 browser.newContext()
  • 是否连接到了错误的 Profile;
  • Cookie 是否真的保存在目标环境中;
  • 页面是否在默认 Context 中创建。

脚本结束后仍有多余标签页

确认业务页面是否由当前任务通过 context.newPage() 创建。

当前任务创建的页面应在 finally 中关闭;原本已经存在并由人工或其他任务维护的页面,不应被自动关闭。

脚本结束后 Profile 仍在运行

检查停止逻辑是否覆盖:

  • Endpoint 获取失败;
  • Playwright 连接失败;
  • 页面加载失败;
  • 业务代码抛出异常;
  • 调度任务被取消。

如果无法确认 Profile 是否由当前任务启动,不要自动关闭,应先记录并核对其运行归属。

正式扩展任务前,还应确认 Playwright 连接的是目标 Profile、页面实际出口与该环境的代理配置一致,并且重启后仍能恢复任务需要的登录状态。

当同一个 Profile 能连续完成启动、Endpoint 获取、Playwright 连接、既有状态复用、页面操作、业务页面关闭、连接断开和 API 回收时,基础的指纹浏览器 API 自动化接入链路就已经跑通。进入批量调度前,再单独补充 Profile 锁、并发限制和运行归属控制。