JS `Chromium DevTools` `Protocol`:自定义调试器与自动化工具

各位观众,各位朋友,大家好!我是今天的讲座主持人,一个喜欢用Chrome DevTools Protocol搞事情的程序员。今天咱们就来聊聊这个听起来有点高大上,但实际上非常好玩的东东:JS Chromium DevTools Protocol,也就是Chrome开发者工具协议。

开场白:谁还没点儿小癖好?

说实话,程序员这行,谁还没点儿小癖好呢?有人喜欢收集键盘,有人喜欢研究算法,而我,偏偏喜欢折腾DevTools Protocol。 为什么? 因为它就像一个万能遥控器,可以控制你的Chrome浏览器,甚至整个Chromium内核。你可以用它来做各种各样有趣的事情,比如:

  • 自动化测试:告别手动点击,让机器人帮你完成重复性的测试任务。
  • 性能分析:深入了解网页的运行机制,找到性能瓶颈并优化。
  • 远程调试:在服务器上运行的headless Chrome,也可以通过协议进行调试。
  • 自定义调试器:打造属于自己的调试工具,满足个性化需求。
  • 网页数据抓取:模拟用户行为,批量获取网页数据。

听起来是不是有点心动了? 别急,咱们一步一步来,先从基础概念开始。

第一章:什么是Chromium DevTools Protocol?

简单来说,Chromium DevTools Protocol(CDP)是一套基于JSON-RPC的通信协议,允许你通过网络连接来控制Chromium内核(包括Chrome浏览器)。你可以把它想象成一个指挥中心,通过发送指令,让浏览器按照你的意愿执行各种操作。

关键概念:

  • JSON-RPC: 一种简单的远程过程调用协议,使用JSON格式进行数据交换。
  • Target: 你要控制的目标,可以是浏览器窗口、标签页、Web Worker等。
  • Command: 你要执行的指令,比如打开网页、点击按钮、获取元素属性等。
  • Event: 浏览器发出的事件通知,比如网页加载完成、元素被修改等。

举个栗子:

假如你想让浏览器打开一个网页,你可以发送这样一个JSON-RPC请求:

{
  "id": 1,
  "method": "Page.navigate",
  "params": {
    "url": "https://www.example.com"
  }
}

其中:

  • id: 请求的唯一标识符,用于区分不同的请求。
  • method: 要执行的命令,这里是Page.navigate,表示导航到指定的URL。
  • params: 命令的参数,这里是url,表示要打开的网页地址。

浏览器收到这个请求后,会打开https://www.example.com,并返回一个响应:

{
  "id": 1,
  "result": {}
}

result为空对象,表示命令执行成功。

第二章:如何连接到DevTools Protocol?

连接到DevTools Protocol的方式有很多种,最常用的方法是使用Node.js库chrome-remote-interface。这个库封装了底层的通信细节,让你更方便地使用CDP。

安装chrome-remote-interface:

npm install chrome-remote-interface

连接到Chrome:

const CDP = require('chrome-remote-interface');

CDP(async (client) => {
  // Extract used DevTools domains.
  const {Network, Page, Runtime} = client;

  // Enable events for the selected domains.
  await Network.enable();
  await Page.enable();

  // Navigate to a page.
  await Page.navigate({url: 'https://www.example.com'});

  // Wait for page load event.
  Page.loadEventFired(async () => {
      console.log('Page loaded!');

      // Evaluate JavaScript on the page.
      const result = await Runtime.evaluate({expression: 'document.title'});
      console.log('Page title:', result.result.value);

      client.close();
  });

}).on('error', (err) => {
  console.error('Cannot connect to Chrome:', err);
});

这段代码做了以下事情:

  1. 引入chrome-remote-interface库。
  2. 使用CDP()函数连接到Chrome。
  3. 获取NetworkPageRuntime这三个域的API。这些域分别负责网络请求、页面操作和JavaScript执行。
  4. 启用NetworkPage域的事件通知。
  5. 使用Page.navigate()命令导航到https://www.example.com
  6. 监听Page.loadEventFired事件,当页面加载完成后,执行回调函数。
  7. 在回调函数中,使用Runtime.evaluate()命令执行JavaScript代码,获取网页的标题。
  8. 最后,关闭连接。

注意: 要让这段代码正常运行,你需要先启动一个Chrome实例,并开启远程调试端口。 你可以在命令行中这样启动Chrome:

chrome --remote-debugging-port=9222

或者,你也可以使用chrome-launcher库来自动启动Chrome:

const chromeLauncher = require('chrome-launcher');
const CDP = require('chrome-remote-interface');

chromeLauncher.launch({
  port: 9222,
  chromeFlags: ['--headless'] // 使用 headless 模式,在后台运行
}).then(chrome => {
  console.log(`Chrome launched with pid ${chrome.pid}`);
  CDP({port: 9222}, async (client) => {
    const {Page, Runtime} = client;
    await Page.enable();
    await Runtime.enable();
    await Page.navigate({url: 'https://www.example.com'});
    Page.loadEventFired(async () => {
      const result = await Runtime.evaluate({expression: 'document.title'});
      console.log('Page title:', result.result.value);
      client.close();
      chrome.kill();
    });
  }).on('error', err => {
    console.error('Cannot connect to Chrome:', err);
  });
});

这段代码会自动启动一个headless Chrome实例,并连接到DevTools Protocol。

第三章:DevTools Protocol的常见Domain与Command

DevTools Protocol提供了大量的Domain(域),每个Domain都包含一系列相关的Command(命令)和Event(事件)。 掌握这些Domain和Command,你就可以随心所欲地控制浏览器了。

下面是一些常用的Domain:

Domain 功能描述 常用Command 常用Event
Page 页面操作,比如导航、刷新、截图等。 navigate(url: string): 导航到指定的URL。 reload(): 重新加载页面。 captureScreenshot(format?: string, quality?: number): 截取屏幕截图。 printToPDF(options?: object): 将页面打印为PDF。 loadEventFired: 页面加载完成。 frameNavigated: 框架导航完成。
Network 网络请求监控和控制,比如拦截请求、修改请求头等。 enable(): 启用网络监控。 disable(): 禁用网络监控。 setRequestInterception(patterns: array): 设置请求拦截规则。 getResponseBody(requestId: string): 获取请求的响应体。 requestWillBeSent: 请求即将发送。 responseReceived: 收到响应。 dataReceived: 收到数据。 loadingFinished: 加载完成。 loadingFailed: 加载失败。
Runtime JavaScript执行环境,比如执行JavaScript代码、获取变量值等。 evaluate(expression: string): 执行JavaScript代码。 callFunctionOn(objectId: string, functionDeclaration: string, arguments?: array): 在指定的对象上调用函数。 getProperties(objectId: string): 获取对象的属性。 executionContextCreated: 执行上下文创建。 executionContextDestroyed: 执行上下文销毁。 exceptionThrown: 抛出异常。
DOM DOM操作,比如获取元素、修改元素属性等。 getDocument(): 获取整个文档的DOM树。 querySelector(nodeId: string, selector: string): 根据CSS选择器查找元素。 setAttributeValue(nodeId: string, name: string, value: string): 设置元素的属性值。 getOuterHTML(nodeId: string): 获取元素的HTML代码。 attributeModified: 属性被修改。 attributeRemoved: 属性被删除。 childNodeCountUpdated: 子节点数量更新。 nodeInserted: 节点被插入。 nodeRemoved: 节点被删除。
Debugger JavaScript调试器,可以设置断点、单步执行等。 enable(): 启用调试器。 disable(): 禁用调试器。 setBreakpointByUrl(lineNumber: number, url: string): 设置断点。 pause(): 暂停执行。 resume(): 继续执行。 stepOver(): 单步跳过。 stepInto(): 单步进入。 stepOut(): 单步跳出。 scriptParsed: 脚本解析完成。 breakpointResolved: 断点已解决。 paused: 暂停。 resumed: 继续。
Console 控制台输出,可以监听控制台消息。 enable(): 启用控制台。 disable(): 禁用控制台。 messageAdded: 添加了控制台消息。
Emulation 模拟设备特性,比如屏幕尺寸、User-Agent等。 setDeviceMetricsOverride(width: number, height: number, deviceScaleFactor: number, mobile: boolean): 设置设备指标覆盖。 setUserAgentOverride(userAgent: string): 设置User-Agent覆盖。 setGeolocationOverride(latitude: number, longitude: number, accuracy: number): 设置地理位置覆盖。
Input 模拟用户输入,比如鼠标点击、键盘输入等。 dispatchMouseEvent(type: string, x: number, y: number, button?: string, clickCount?: number): 模拟鼠标事件。 dispatchKeyEvent(type: string, key?: string, code?: string, modifiers?: number): 模拟键盘事件。 insertText(text: string): 插入文本。
Target 管理目标对象,比如创建、关闭标签页等。 createTarget(url: string): 创建一个新目标。 closeTarget(targetId: string): 关闭目标。 attachToTarget(targetId: string, flatten?: boolean): 连接到目标。 detachFromTarget(sessionId: string): 从目标断开连接。 targetCreated: 目标已创建。 targetDestroyed: 目标已销毁。 targetAttached: 目标已连接。 targetDetached: 目标已断开连接。
Storage 操作浏览器存储,比如Cookie、LocalStorage等。 getCookies(browserContextId?: string): 获取Cookie。 setCookies(cookies: array, browserContextId?: string): 设置Cookie。 clearCookies(browserContextId?: string): 清除Cookie。 getLocalStorage(storageId: object): 获取LocalStorage。 setLocalStorageItem(storageId: object, key: string, value: string): 设置LocalStorage。

这只是冰山一角,DevTools Protocol还提供了很多其他的Domain和Command。 你可以通过官方文档了解更多细节:https://chromedevtools.github.io/devtools-protocol/

第四章:实战演练:自动化测试

现在,咱们来做一个简单的自动化测试示例:自动登录某个网站。

假设我们要登录的网站的HTML结构如下:

<input type="text" id="username" name="username">
<input type="password" id="password" name="password">
<button id="login-button">登录</button>

代码如下:

const chromeLauncher = require('chrome-launcher');
const CDP = require('chrome-remote-interface');

const username = 'your_username';
const password = 'your_password';
const loginUrl = 'https://your_login_page.com';

chromeLauncher.launch({
  port: 9222,
  chromeFlags: ['--headless']
}).then(chrome => {
  CDP({port: 9222}, async (client) => {
    const {Page, Runtime, DOM, Input} = client;
    await Page.enable();
    await Runtime.enable();
    await DOM.enable();
    await Input.enable();

    await Page.navigate({url: loginUrl});
    await Page.loadEventFired();

    // 找到用户名输入框
    const usernameInput = await DOM.querySelector({selector: '#username', nodeId: 1}); // nodeId: 1 是document根节点的固定值

    // 输入用户名
    await Input.insertText({text: username});

    // 找到密码输入框
    const passwordInput = await DOM.querySelector({selector: '#password', nodeId: 1});

    // 输入密码
    await Input.insertText({text: password});

    // 找到登录按钮
    const loginButton = await DOM.querySelector({selector: '#login-button', nodeId: 1});

    // 获取登录按钮的坐标
    const boxModel = await DOM.getBoxModel({nodeId: loginButton.nodeId});
    const {content} = boxModel.model;

    // 计算登录按钮的中心坐标
    const x = content[0] + (content[2] - content[0]) / 2;
    const y = content[1] + (content[5] - content[1]) / 2;

    // 模拟鼠标点击登录按钮
    await Input.dispatchMouseEvent({
        type: 'mousePressed',
        x: x,
        y: y,
        button: 'left',
        clickCount: 1
    });

    await Input.dispatchMouseEvent({
        type: 'mouseReleased',
        x: x,
        y: y,
        button: 'left',
        clickCount: 1
    });

    // 等待页面跳转(这里可以根据实际情况调整等待时间)
    await new Promise(resolve => setTimeout(resolve, 3000));

    // 获取当前页面的URL,判断是否登录成功
    const result = await Runtime.evaluate({expression: 'window.location.href'});
    const currentUrl = result.result.value;

    if (currentUrl !== loginUrl) {
      console.log('登录成功!');
    } else {
      console.log('登录失败!');
    }

    client.close();
    chrome.kill();
  }).on('error', err => {
    console.error('Cannot connect to Chrome:', err);
  });
});

这段代码模拟了用户在登录页面输入用户名、密码,并点击登录按钮的操作。

关键点:

  • 使用DOM.querySelector()方法查找元素。注意,nodeId需要从DOM树的根节点开始查找,根节点的nodeId通常是1。
  • 使用Input.insertText()方法输入文本。
  • 使用DOM.getBoxModel()方法获取元素的坐标。
  • 使用Input.dispatchMouseEvent()方法模拟鼠标点击事件。
  • 在点击按钮后,需要等待页面跳转完成。

第五章:高级技巧:拦截网络请求

DevTools Protocol还可以用来拦截网络请求,这在很多场景下都非常有用,比如:

  • 修改请求头:可以用来模拟不同的User-Agent,或者添加自定义的请求头。
  • Mock数据:可以用来模拟服务器返回的数据,方便进行单元测试。
  • 阻止加载:可以用来阻止加载某些资源,比如广告。

代码如下:

const chromeLauncher = require('chrome-launcher');
const CDP = require('chrome-remote-interface');

chromeLauncher.launch({
  port: 9222,
  chromeFlags: ['--headless']
}).then(chrome => {
  CDP({port: 9222}, async (client) => {
    const {Network, Page} = client;
    await Network.enable();
    await Page.enable();

    // 设置请求拦截规则,拦截所有图片
    await Network.setRequestInterception({
      patterns: [{urlPattern: '*.(jpg|jpeg|png|gif)', resourceType: 'Image', interceptionStage: 'HeadersReceived'}]
    });

    Network.requestIntercepted(async (params) => {
      const {interceptionId} = params;

      // 阻止加载图片
      await Network.continueInterceptedRequest({
        interceptionId: interceptionId,
        errorReason: 'BlockedByClient' // 设置错误原因,阻止加载
      });
    });

    await Page.navigate({url: 'https://www.example.com'});
    await Page.loadEventFired();

    console.log('Page loaded, images blocked!');

    client.close();
    chrome.kill();
  }).on('error', err => {
    console.error('Cannot connect to Chrome:', err);
  });
});

这段代码拦截了所有图片请求,并阻止了图片的加载。

关键点:

  • 使用Network.setRequestInterception()方法设置请求拦截规则。
  • 监听Network.requestIntercepted事件,当请求被拦截时,执行回调函数。
  • 在回调函数中,使用Network.continueInterceptedRequest()方法,可以修改请求或者阻止请求。
  • errorReason: 'BlockedByClient'表示由于客户端的原因,阻止了请求。

第六章:踩坑指南与注意事项

在使用DevTools Protocol的过程中,你可能会遇到一些坑,下面是一些常见的坑和注意事项:

  • Chrome版本兼容性:不同的Chrome版本可能支持不同的DevTools Protocol版本,需要注意版本兼容性。
  • 异步操作:DevTools Protocol的所有操作都是异步的,需要使用async/await或者Promise来处理异步结果。
  • 错误处理:需要对可能出现的错误进行处理,比如连接失败、命令执行失败等。
  • 资源释放:在使用完DevTools Protocol后,需要关闭连接,释放资源。
  • headless模式:在使用headless模式时,需要注意一些限制,比如无法使用GUI相关的API。
  • Session Management:当与多个 targets 交互,特别是使用 Target.attachToTarget 时,正确的 session 管理至关重要。 不正确的 session 处理可能导致命令发送到错误的 target 或泄漏资源。
  • Performance Overhead: 频繁地发送命令和事件监听可能会引入性能开销。 优化你的代码,避免不必要的交互。

总结与展望

Chromium DevTools Protocol是一个非常强大的工具,可以用来做各种各样有趣的事情。 掌握了CDP,你就可以打造属于自己的调试器、自动化工具、数据抓取工具等等。

希望今天的讲座能够帮助你入门DevTools Protocol,并激发你的创造力。 记住,编程的乐趣在于探索和创造,祝你在DevTools Protocol的世界里玩得开心!

最后,留一个思考题:

如何使用DevTools Protocol来监控网页的性能指标,比如First Contentful Paint (FCP) 和 Largest Contentful Paint (LCP)? 欢迎在评论区分享你的答案!

感谢大家的观看,我们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注