手寫 Puppeteer:遠程控制 Chromiu

你是否好奇過 Puppeteer 的遠程控制是怎麼實現的呢?

其實是基於 Chrome DevTools Protocol,它是 chrome devtools 和 chromium 通信的協議,chrome devtools 用它來獲取 chromium 的一些信息,並且還可以控制 chromium 來做一些事情。

你可以在 chrome devtools 裏打開 Protocol Monitor:

然後就可以看到 chrome devtools 和 chromium 通信的所有 CDP 協議數據了:

chrome devtools 裏展示的數據,控制瀏覽器執行一些行爲,都是通過這個實現的,Puppeteer 也同樣是基於這個。

你可以打開 CDP 的文檔看到協議的詳細描述:

它是分爲不同的域的,比如 Page、Browser、Network 等,分區來管理不同的協議。

比如 Page.navigate 可以讓頁面導航到某個 url:

Page.close 可以關閉頁面

Browser.close 可以關閉瀏覽器

Puppeteer 就是基於這些來遠程控制 Chromium 的。

我們來實現一下。

首先,我們手動走下這個流程:

啓動前面下載的 Chromium 瀏覽器,指定啓動參數 --remote-debugging-port 和 --user-data-dir

--remote-debugging-port 就是調試服務的啓動端口,--user-data-dir 是保存用戶數據的地方

用戶數據是指插件、瀏覽記錄、歷史、Cookie、網站數據等所有用戶使用瀏覽器時的數據,指定了 userDataDir,chromium 就會把數據保存在那個目錄:

但這個參數在低版本的 chromium 不支持,所以如果有報錯就用版本高一點的 chromium 來跑,比如我這裏用的是 970501

以調試模式跑起 Chromium 之後,訪問 http://localhost:9929/json/list 就可以看到每個頁面的 ws 服務的信息,可以連上每個頁面進行調試:

比如我再訪問下 baidu 和 juejin,就會多這倆頁面的 ws 調試服務的信息:

我們可以用 http://localhost:9929/json/list 這個頁面是否可以打開來判斷瀏覽器是否以調試模式啓動成功了。

然後你還會發現 /json/new 可以新建一個頁面:

Puppeteer 新建頁面也是這樣實現的。

下面我們把這個流程用代碼來實現一下:

我們先處理下 chromium 的啓動參數,也就是 user-data-dir、remote-debugging-port 等這些:

let browserId = 0;

//用戶數據目錄
const CHROME_PROFILE_PATH = path.resolve(__dirname, '..''.dev_profile');

class Browser {

    constructor(options) {
        options = options || {};

        ++browserId;
        this._userDataDir = CHROME_PROFILE_PATH + browserId;

        this._remoteDebuggingPort = 9229;
        if (typeof options.remoteDebuggingPort === 'number') {
            this._remoteDebuggingPort = options.remoteDebuggingPort;
        }
        this._chromeArguments = [ 
            `--user-data-dir=${this._userDataDir}`,
            `--remote-debugging-port=${this._remoteDebuggingPort}`,
        ];

        if (options.headless) {
            this._chromeArguments.push(`--headless`);
        }

        if (typeof options.executablePath === 'string') {
            this._chromeExecutable = options.executablePath;
        } else {
            const chromiumRevision = require('../package.json').puppeteer.chromium_revision;
            this._chromeExecutable = Downloader.executablePath(chromiumRevision);
        }

        if (Array.isArray(options.args))
            this._chromeArguments.push(...options.args);

        this._chromeProcess = null;
    }
}

這段邏輯就是 Browser 的啓動參數的處理,包括啓動路徑 _chromeExecutable,啓動參數 user-data-dir 的路徑、headless、remote-debugging-port。

啓動參數有了,接下來就是啓動 Chromium 了:

const childProcess = require('child_process');
const removeRecursive = require('rimraf').sync;

async launch() {
    if (this._chromeProcess)
        return;
    this._chromeProcess = childProcess.spawn(this._chromeExecutable, this._chromeArguments, {});

    process.on('exit'() => this._chromeProcess.kill());
    this._chromeProcess.on('exit'() => removeRecursive(this._userDataDir));
}

啓動 chromium 就是通過 childProcess 以子進程的方式啓動,並且在它退出的時候遞歸刪除下用戶數據目錄。

這裏的 rimraf 是第三方的包,node 只提供了刪除單個文件或目錄的 api fs.unlink,不支持遞歸刪除。

這樣就通過代碼的方式把我們手動啓動瀏覽器的步驟給自動化了。

CDP 協議只有以調試模式啓動 Chromium 的時候才能生效,所以我們要保證它是啓在調試模式的,也就是訪問下 http://localhost:9929/json/list 是有數據的:

所以要加一段這樣的邏輯:

function waitForChromeResponsive(remoteDebuggingPort) {
    var resolve;
    const promise = new Promise(x =resolve  = x);

    const options = {
        method: 'GET',
        host: 'localhost',
        port: remoteDebuggingPort,
        path: '/json/list'
    };
    sendRequest();
    return promise;

    function sendRequest() {
        const req = http.request(options, res ={
            resolve ()
        });
        req.on('error'e => setTimeout(sendRequest, 100));
        req.end();
    }
}

就是訪問下這個 url,如果成功就 resolve promise,否則定時重試。

經過這個驗證之後,之後就可以通過 CDP 來和 chromium 通信了。

這個方法我們可以把它叫做 _ensureChromeIsRunning,確保 chrome 在調試模式運行的方法:

async launch() {
    await this._ensureChromeIsRunning();
}

async _ensureChromeIsRunning() {
    if (this._chromeProcess)
        return;
    this._chromeProcess = childProcess.spawn(this._chromeExecutable, this._chromeArguments, {});

    process.on('exit'() => this._chromeProcess.kill());
    this._chromeProcess.on('exit'() => removeRecursive(this._userDataDir));

    await waitForChromeResponsive(this._remoteDebuggingPort);
}

之後就開始通過 CDP 控制瀏覽器。

這個 CDP 的 WebSocket 通信過程也不用我們自己搞,chrome 提供了一個 chrome-remote-interface 的包。

比如我們可以用它新建一個頁面:

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

async newPage() {
    await this._ensureChromeIsRunning();

    if (!this._chromeProcess || this._chromeProcess.killed) {
        throw new Error('ERROR: this chrome instance is not alive any more!');
    }

    const tab = await CDP.New({port: this._remoteDebuggingPort});
}

跑起來確實可以看到 chromium 新建了一個頁面,這就是我們實現的第一個遠程控制效果!(原理就是訪問 /json/new)

接下來進行更多的 page 的控制,Page 級別的控制我們單獨封裝一下,放到 Page 的類裏:

class Page{

    static async create(browser, client) {
        await client.send('Page.enable'{});

        const page = new Page(browser, client);
        return page;
    }

    constructor(browser, client) {
        this._browser = browser;
        this._client = client;
    }
}

需要傳入瀏覽器實例和 CDP 客戶端。

所以在 Browser 的 newPage 方法裏就創建個 page 的對象返回,之後的控制都交給它:

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

async newPage() {
    await this._ensureChromeIsRunning();

    if (!this._chromeProcess || this._chromeProcess.killed) {
        throw new Error('ERROR: this chrome instance is not alive any more!');
    }
    const tab = await CDP.New({port: this._remoteDebuggingPort});
    
    const client = await CDP({tab: tab, port: this._remoteDebuggingPort});
    const page = await Page.create(this, client);
    page[this._tabSymbol] = tab;
    return page;
}

CDP 傳入 port 參數和 tab 參數,那連接的就是這個 tab 頁面的 ws 調試服務,也就是我們在 /json/list 裏看到的那個:

之後開始做一些頁面級別的控制:

CDP 每個域的使用都要先開啓下,創建 Page 對象的時候我們已經開啓了 Page 域的協議:

然後實現個 navigate 方法:

async navigate(url) {
    var loadPromise = new Promise(resolve => this._client.once('Page.loadEventFired', resolve)).then(() =true);

    await this._client.send('Page.navigate'{url});
    return await loadPromise;
}

通過 CDP 協議裏的 Page.navigate 來導航到某個 url,在 Page.loadEventFired 的時候 resolve。

然後再實現個 setContent 方法:

async setContent(html) {
    var resourceTree = await this._client.send('Page.getResourceTree'{});
    await this._client.send('Page.setDocumentContent'{
        frameId: resourceTree.frameTree.frame.id,
        html: html
    });
}

這個是設置 Page 的 html 內容的 CDP 協議,需要傳入 frameId,這個可以通過 Page.getResourceTree 拿到。

最後我們再去 Browser 那裏實現倆方法,之後再一起測試。

加一個 version 方法,用於獲取瀏覽器版本:

async version() {
    await this._ensureChromeIsRunning();
    const version = await CDP.Version({port: this._remoteDebuggingPort});
    return version.Browser;
}

加一個 close 方法用於關閉瀏覽器:

close() {
    if (!this._chromeProcess)
        return;
    this._chromeProcess.kill();
}

至此,全部搞定之後,我們整體來調用一下:

const Browser = require('./lib/Browser');

const browser = new Browser({
    remoteDebuggingPort: 9229,
    headless: false
});

function delay(time) {
    return new Promise((resolve => setTimeout(resolve, time)))
}

(async function() {
    await browser.launch();

    const page = await browser.newPage();
    await page.navigate('https://www.baidu.com');

    await delay(2000);
    const version = await browser.version();
    await page.setContent(`<h1 style="font-size:50px">hello, ${version}</h1>`);

    await delay(2000);
    await page.close();

    await delay(1000);
    await browser.close();
})()

我們創建了一個 Browser,傳入啓動參數,然後把它跑起來,之後創建了個新頁面,導航到 baidu,2s 後修改了內容,再 2s 關閉頁面,之後再 1s 關閉瀏覽器。

我們跑一下試試:

可以看到,Chromium 正確執行了我們寫的腳本!

至此,我們實現了 Puppeteer 的基本功能(代碼和上集同一個倉庫)。

總結

這一集我們實現了啓動 Chromium 並遠程控制。

Chromium 指定 remote-debugging-port 的參數的時候就會以調試模式來跑,如果可以通過 http://localhost:9229/json/list 拿到調試的數據就證明啓動成功了。

之後可以通過 /json/new 創建新頁面,再通過 CDP 協議來進行頁面級別的控制,這就是 Puppeteer 遠程控制的原理。

我們實現了瀏覽器的打開、關閉、查看版本號,頁面的新建、導航、設置內容等功能。

這已經有 Puppeteer 的雛形了,下一集我們實現更多的遠程控制功能。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9YKQcxtsneW2LzaddKim9Q