深入淺出 CDP (Chrome DevTools Protocol)

深入淺出 CDP (Chrome DevTools Protocol) - Clericpy's Blog

深入淺出 CDP (Chrome DevTools Protocol)

14 Jan 2020

背景

​ 自從 Chrome 59 發佈支持 –headless 啓動參數以後 (Windows 上是 60 版本), 輕量級瀏覽器內核就不再是 webdriver 一家獨大, 甚至 phantomjs 作者也發文表示不再維護該項目, 國外也有越來越多的文章推薦使用 headless Chrome 代替過去 selenium + webdriver 的方式進行 Web 測試或者爬蟲相關工作. 目前國內實際上使用 headless Chrome 的並不少, 只不過目前大量營銷號的存在, 導致爲了點擊量頻繁刷文, 進而把早年間 selenium 用作爬蟲的舊文章重新翻到讀者眼前, 所以遇到各種稀奇古怪的問題, 初學者使用體驗較差. selenium 作爲老牌 Web 測試手段聞名已久, 在高級功能 API 層面非常成熟, 後來也加強了對 Chrome headless 模式下 CDP 的支持, 目前依然擁有大量用戶在使用.這裏, 簡單提一下 selenium + webdriver 方式的一些不足:

  1. 默認參數啓動時很容易被服務端發現
  2. 性能與 Chrome headless 相比, 較差
  3. 存在了無數年的內存泄漏問題
  4. 不像 Chrome 有大廠在背後支撐, 上千 issues 解決不完
  5. 無法作爲完整瀏覽器使用和調試

​ 簡而言之, 都 2020 年了, 不要再抱着 selenium 不放了

概述

CDP

Chrome DevTools Protocol 的簡稱, 通過 CDP, 可以檢查/調試/監聽網絡流量, Chrome 瀏覽器的調試工具 Chrome DevTools 使用的也是這套協議, 支持 Chrome, Chromium 等所有基於 Blink 的瀏覽器.CDP 官方文檔: https://chromedevtools.github.io/devtools-protocol/

交流方式

通過 HTTP, WebSocket 兩種方式, 對添加了遠程調試接口參數( --remote-debugging-port=9222 )的瀏覽器進行遠程調試, 大部分功能其實與瀏覽器手機打開的 devtools 一致

1. HTTP 負責總覽當前 Tabs 信息
2. 每個 Tab 的對話使用 WebSocket 建立連接, 並接收已開啓功能 (enabled domain) 的事件消息.

Headless Browser

俗稱的無頭瀏覽器, 實際上就是沒有圖形界面的瀏覽器, 因爲省去了視覺渲染的工作, 性能和開銷有較大優化, 粗略估計, 原本只能啓動十個瀏覽器的內存, 使用 Headless 模式可以至少啓動三倍的數量

常見用途

  1. 主要還是 Web 測試
  2. 少數情況會用來做爬蟲, 所見即所得的調試體驗非常容易上手
  3. 有一些 Web 自動化的工作, 可以替代自己寫擴展或者 tampermonkey JS 腳本, 畢竟權限更高更全面, GUI 模式調試完以後, 無人蔘與操作的多數情況, 則可以無痛改成 –Headless 模式來提高性能

常見問題

  1. Chrome 瀏覽器有一個併發連接數的限制. 即對同一個網站, 只允許建立最多 6 個連接(純靜態情況下, 可以看作是 6 個同 domain 的 Tabs). 如果真的遇到超過 6 個連接的需求, 可以通過新開一個瀏覽器實例來解決.
  2. 對於 Linux 來說, 子進程處理不正確會導致出現殭屍進程/孤兒進程, 導致白白浪費資源, 時間長了整臺服務器的內存都會垮掉. 常見解決方案有 3 種
    1. 將 Chrome 守護進程 (Daemon) 與業務代碼隔離, 隨需要啓動對應數量的 Chrome 實例
    2. 就 Python subprocess 這個內置模塊來說, 確定每次關閉的時候執行正確的姿勢
      1. 調用 Browser.close 功能 gracefully 地關閉瀏覽器
      2. 然後 terminate 子進程後, 記得 wait 一下消息
      3. 最後保險起見可以再加個 kill, 雖然實際沒什麼用
    3. 最簡單的其實是找到 chrome 實例的進程 ID 來殺, 畢竟殺死以後, subprocess 那邊立刻就結束了
  3. 神奇的是, 除了 chrome 實例有殭屍進程, 連 tab 也會存在一些看不見 ( /json 裏那些非 “page” 類型的就是了)或關不掉(殭屍標籤頁)的 tab 頁
    1. 目前這種 tab 不確定會不會自己關閉, 訪問 B 站遇到過
    2. 以前我處理這種 tab 的方式是給每個 tab 設定一個 lifespan, 異步一個循環, 掃描並關閉那些非 page 類型或者壽命超時了的 tab
    3. 然而 tab 數量多了以後, 反而會出現很多無法關閉的殭屍 tab, 通過 /json/close 或者發送 Page.close 事件都無效, 暫時只好重啓 chrome 實例來清理
  4. 拿來做爬蟲還有幾個問題沒解決
    1. chronium 開發團隊本着 “你並不是真的特別需要” 原則, 沒有動態掛代理的開發意向, 畢竟人家也不太希望人們拿它來做爬蟲, 只能指望不同代理 IP 啓動多個 chrome 實例來解決
    2. 在 “非 headless” 情況下, 可以通過代理擴展, 或者 pac 文件, 來搞定動態代理的問題
    3. 在 headless 的模式, 那就只好從 upstream 角度搞了, 甚至掛上 mitmproxy 也行吧
    4. 至於動態修改 UA, 暫時可以用擴展來搞, 不過如果喜歡鑽研, 可以發現 CDP 裏支持動態修改 Request 的各項屬性, 在這裏改 headers 是有效的

文檔

常用功能

Chrome DevTools Protocol 文檔的使用, 主要還是使用裏面的檢索功能, 不過最常用的還是以下幾個領域

  1. Page
    1. 簡單地理解, 可以把一個 Page 看成一個 Page 類型的 Tab
    2. 對 Tab 的刷新, 跳轉, 停止, 激活, 截圖等功能都可以找到
    3. 也會有很多有用的事件需要 enable Page 以後才能監聽到, 比如 loadEventFired
    4. 多個網站的任務, 可以在同一個瀏覽器裏打開多個 Tab 進行操作, 通過不同的 Websocket 地址進行連接, 相對隔離, 並且託異步模型的福, Chrome 多個標籤操作的抗壓能力還不錯
    5. 然而併發操作多個 Tab 的時候, 可能會出現一點小問題需要注意: 同一個瀏覽器實例, 對一個域名只能建立 6 個連接, 這個不太好改; 過快生成大量 Tab, 可能會導致有的 Tab 無法正常關閉(zombie tabs)
  2. Network
    1. 和產生網絡流量有關係的大都在這個 Domain
    2. 比如 setExtraHTTPHeaders / setUserAgentOverride 對當前標籤頁的所有請求修改原是參數
    3. 比如對 cookie 的各種操作
    4. 通過 responseReceived + getResponseBody 來監聽流量, 只用前者就能嗅探到 mp4 這種特殊類型的 url 了, 而後者可以把流量裏已經 base64 化的數據進行其他操作, 比如驗證碼圖片的處理
  3. 其他功能也基本和 devtools 一致

常規姿勢

  1. 和某個 Tab 建立連接
  2. 通過 send 發送你想使用的 methods
  3. 通過 recv 監聽你發送 methods 產生的事件, 或者其他 enable 的事件, 並執行對應回調

實踐

準備工作

  1. 安裝 chrome 瀏覽器

  2. 安裝 Python3.7

    • pip install ichrome -U
      • ichrome 庫是可選的, 主要是爲了演示通過 HTTP / Websocket client 與 chrome 實例實現通信
      • ichrome 庫除了協程實現, 也有一個同步實現, 觀察它的源碼比協程版本的更直觀一點, 也易於學習

啓動調試模式下的 chrome

from ichrome import ChromeDaemon


def launch_chrome():
    with ChromeDaemon(host="127.0.0.1", port=9222, max_deaths=1) as chromed:
        chromed.run_forever()


if __name__ == "__main__":
    launch_chrome()

以上代碼負責啓動 chrome 調試模式的守護進程, 具體參數如下:

  1. **chrome_path: **表示 chrome 的可執行路徑 / 命令, 默認爲 None 的時候, 會自動根據操作系統去嘗試找尋 chrome 路徑, 如 linux 下的 google-chrome 和 google-chrome-stable, macOS 下的 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome, 或者 Windows 下的

    1. C:/Program Files (x86)/Google/Chrome/Application/chrome.exe
    2. C:/Program Files/Google/Chrome/Application/chrome.exe
    3. “%s\AppData\Local\Google\Chrome\Application\chrome.exe” % os.getenv(“USERPROFILE”)
  2. **host: ** 默認爲 127.0.0.1, 之所以不用 localhost, 是因爲很多 Windows / macOS 的 etc/hosts 文件裏被強制綁定到了 ipv6 地址上

  3. **port: ** 默認爲 9222

  4. **headless: ** 常見參數 –headless, –hide-scrollbars, 放在初始化參數裏了

  5. **user_agent: ** 常見參數 –user-agent

  6. **proxy: ** 常見參數 –proxy-server

  7. **user_data_dir: ** 避免 chrome 到處亂放 user data, 所以默認會放到 user 目錄下的 ichrome_user_data 文件夾下, 命名按端口號 chrome_9222

  8. **disable_image: ** 常用參數 –blink-settings=imagesEnabled=false, 從 blink 層面禁用, 比其他禁止圖片加載的方式要靠譜

  9. **max_deaths: ** 用來自動重啓, max_deaths=2 表示快速殺死 chrome 實例 2 次才能避免再次自動重啓, 所以默認爲 1

  10. **extra_config: ** 就是添加其他更多 chrome 啓動的參數, 參數類型爲 list

啓動帶圖形界面的 chrome 之後, 可以手動嘗試下通過 http 請求和 chrome 實例通信了

  1. 訪問 http://127.0.0.1:9222/json , 會拿到一個列出當前 tabs 信息的 json
  2. 其他操作參考 https://chromedevtools.github.io/devtools-protocol/ (HTTP Endpoints 部分)
[
    {
        "description": "",
        "devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/E6826ED4A0365605F3234B2A441B1D03",
        "id": "E6826ED4A0365605F3234B2A441B1D03",
        "title": "about:blank",
        "type": "page",
        "url": "about:blank",
        "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/E6826ED4A0365605F3234B2A441B1D03"
    }
]

操作 Tab

  1. 建立到 webSocketDebuggerUrl 的 Websocket 連接, 然後監聽事件
  2. 大部分功能 ichrome 已經打包好了
from ichrome import AsyncChrome
import asyncio


async def async_operate_tab():
    chrome = AsyncChrome(host='127.0.0.1', port=9222)
    if not await chrome.connect():
        raise RuntimeError
    tab = (await chrome.tabs)[0]
    async with tab():
        # 跳轉到 httpbin, 3 秒 loading 超時的話則 stop loading
        await tab.set_url('http://httpbin.org', timeout=3)
        # 注入 js, 並查看返回結果
        result = await tab.js("document.title")
        title = result['result']['result']['value']
        # 打印 title
        print(title)
        # httpbin.org
        # 通過 js 修改 title
        await tab.js("document.title = 'New Title'")
        # click 一個 css 選擇器的位置, 跳轉到了 Github
        await tab.click('body > a:first-child')
        # 等待加載完成
        await tab.wait_loading(3)

        async def callback_function(request):
            if request:
                # 監聽到經過過濾的流量, 等待它加載一會比較保險
                for _ in range(3):
                    result = await tab.get_response(request)
                    if result.get('error'):
                        await tab.wait_loading(1)
                        continue
                    # 拿到整個 html
                    body = result['result']['body']
                    print(body)

        def filter_func(r):
            url = r['params']['response']['url']
            print('received:', url)
            return url == 'https://github.com/'

        # 監聽流量, 需要異步處理, 則使用 asyncio.ensure_future 即可
        # 監聽 10 秒
        task = asyncio.ensure_future(
            tab.wait_response(
                filter_function=filter_func,
                callback_function=callback_function,
                timeout=10),
            loop=tab.loop)
        # 點擊一下左上角的小章魚則會觸發流量
        await tab.click('[href="https://github.com/"]')
        # 等待監聽流量
        await task


if __name__ == "__main__":
    asyncio.run(async_operate_tab())

總結

​ CDP 單單入門的話, 其實沒想象中那麼複雜, chrome 59 剛出的時候, puppeteer 都沒的用, 更別說 pyppeteer 之類的包裝, 看了幾個早期項目的源碼, 發現簡單使用的話, 其實主要就是:

1. HTTP
2. Websocket
3. Javascript
4. Protocol

​ pyppeteer 誕生之初曾體驗了一下, 第一步就因爲一些不可抗力導致下載 chromium 失敗, 所以之後只能閱讀一下里面一些有意思的源碼, 主要看了下如何從 puppeteer 原生事件驅動轉爲 Python 角度的事件, pyee 的使用也讓人眼前一亮

​ 之後自己摸索過程中也碰到了各種各樣問題, 除了上面提到的, 其實還遇到 Websocket 粘包(粘包本身就是個因爲理解不足導致的僞命題), Chrome Headless 閹割掉了很多基礎功能也使開發過程中總是無理由地調試失敗, 甚至關閉 user-dir 使用匿名模式導致一系列不知名故障也是費心費力, 不過總體來說收穫頗大 用 Python 來操作 chrome 能做的事情挺多, 尤其是各路籤到爬蟲, 或者索取微信公衆平臺大概 20 小時有效期的 cookie / token 給後臺爬蟲使用, 採集視頻, 自動化測試時候截圖, 啓動 Headless 模式以後節省了很多手動操作的時間, 甚至可以丟到無 GUI 的 linux server 上去. 要知道以前指望的還是 tampermonkey 或者手寫擴展, 很多 Python 的功能只能轉 js 再用, 勞心勞力.

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章