Puppeteer 是 Chrome 開發團隊在 2017 年發布的一個 Node.js 包,用來模擬 Chrome 瀏覽器的運行。我們團隊從 Puppeteer 剛發布出來就開始成為忠實用戶了(主要是因為 PhantomJs 坑太多了),本文主要在介紹 Puppeteer 的同時,結合我們平時的實踐做一個分享。
學習 Puppeteer 之前我們先來了解一下 Chrome DevTool Protocol
什么是 Chrome DevTool Protocol
CDP 基於 WebSocket,利用 WebSocket 實現與瀏覽器內核的快速數據通道
CDP 分為多個域(DOM,Debugger,Network,Profiler,Console…),每個域中都定義了相關的命令和事件(Commands and Events)
我們可以基於 CDP 封裝一些工具對 Chrome 瀏覽器進行調試及分析,比如我們常用的 “Chrome 開發者工具” 就是基於 CDP 實現的
如果你以 remote-debugging-port 參數啟動 Chrome,那么就可以看到所有 Tab 頁面的開發者調試前端頁面,還會在同一端口上還提供了 http 服務,主要提供以下幾個接口:
GET /json/version # 獲取瀏覽器的一些元信息 GET /json or /json/list # 當前瀏覽器上打開的一些頁面信息 GET /json/protocol # 獲取當前 CDP 的協議信息 GET /json/new?{url} # 開啟一共新的 Tab 頁面 GET /json/activate/{targetId} # 激活某個頁面成為當前顯示的頁面 GET /json/close/{targetId} # 關閉某個頁面 GET /devtools/inspector.html # 打開當前頁面的開發者調試工具
WebSocket /devtools/page/{targetId} # 獲取某個頁面的 websocket 地址
很多有用的工具都是基於 CDP 實現的,比如 Chrome 開發者工具,chrome-remote-interface,Puppeteer 等
什么是 Headless Chrome
在無界面的環境中運行 Chrome
通過命令行或者程序語言操作 Chrome
無需人的干預,運行更穩定
沒有界面,少了真實瀏覽器加載 css/js 以及渲染頁面的工作,無頭測試要比真實瀏覽器更快
在啟動 Chrome 時添加參數 --headless,便可以 headless 模式啟動 Chrome
alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome" # Mac OS X 命令別名 chrome --headless --remote-debugging-port=9222 --disable-gpu # 開啟遠程調試 chrome --headless --disable-gpu --dump-dom https://www.baidu.com # 獲取頁面 DOM chrome --headless --disable-gpu --screenshot https://www.baidu.com # 截圖
chrome 啟動時可以加一些什么參數,大家可以點擊這里查看
Puppeteer 是什么
Puppeteer 是 Node.js 工具引擎
Puppeteer 提供了一系列 API,通過 Chrome DevTools Protocol 協議控制 Chromium/Chrome 瀏覽器的行為
Puppeteer 默認情況下是以 headless 啟動 Chrome 的,也可以通過參數控制啟動有界面的 Chrome
Puppeteer 默認綁定最新的 Chromium 版本,也可以自己設置不同版本的綁定
Puppeteer 讓我們不需要了解太多的底層 CDP 協議實現與瀏覽器的通信
Puppeteer 能做什么
官方稱:“Most things that you can do manually in the browser can be done using Puppeteer”,那么具體可以做些什么呢?
網頁截圖或者生成 PDF
爬取 SPA 或 SSR 網站
UI 自動化測試,模擬表單提交,鍵盤輸入,點擊等行為
捕獲網站的時間線,幫助診斷性能問題
創建一個最新的自動化測試環境,使用最新的 js 和最新的 Chrome 瀏覽器運行測試用例
測試 Chrome 擴展程序
…
Puppeteer API 分層結構
Puppeteer 中的 API 分層結構基本和瀏覽器保持一致,下面對常使用到的幾個類介紹一下:

Browser: 對應一個瀏覽器實例,一個 Browser 可以包含多個 BrowserContext
BrowserContext: 對應瀏覽器一個上下文會話,就像我們打開一個普通的 Chrome 之后又打開一個隱身模式的瀏覽器一樣,BrowserContext 具有獨立的 Session(cookie 和 cache 獨立不共享),一個 BrowserContext 可以包含多個 Page
Page:表示一個 Tab 頁面,通過 browserContext.newPage()/browser.newPage() 創建,browser.newPage() 創建頁面時會使用默認的 BrowserContext,一個 Page 可以包含多個 Frame
Frame: 一個框架,每個頁面有一個主框架(page.MainFrame()),也可以多個子框架,主要由 iframe 標簽創建產生的
ExecutionContext: 是 javascript 的執行環境,每一個 Frame 都一個默認的 javascript 執行環境
ElementHandle: 對應 DOM 的一個元素節點,通過該該實例可以實現對元素的點擊,填寫表單等行為,我們可以通過選擇器,xPath 等來獲取對應的元素
JsHandle:對應 DOM 中的 javascript 對象,ElementHandle 繼承於 JsHandle,由於我們無法直接操作 DOM 中對象,所以封裝成 JsHandle 來實現相關功能
CDPSession:可以直接與原生的 CDP 進行通信,通過 session.send 函數直接發消息,通過 session.on 接收消息,可以實現 Puppeteer API 中沒有涉及的功能
Coverage:獲取 JavaScript 和 CSS 代碼覆蓋率
Tracing:抓取性能數據進行分析
Response: 頁面收到的響應
Request: 頁面發出的請求
如何創建一個 Browser 實例
puppeteer 提供了兩種方法用於創建一個 Browser 實例:
puppeteer.connect: 連接一個已經存在的 Chrome 實例
puppeteer.launch: 每次都啟動一個 Chrome 實例
const puppeteer = require('puppeteer');
let request = require('request-promise-native');
//使用 puppeteer.launch 啟動 Chrome
(async () => {
const browser = await puppeteer.launch({
headless: false, //有瀏覽器界面啟動
slowMo: 100, //放慢瀏覽器執行速度,方便測試觀察
args: [ //啟動 Chrome 的參數,詳見上文中的介紹
'–no-sandbox',
'--window-size=1280,960'
],
});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
await page.close();
await browser.close();
})();
//使用 puppeteer.connect 連接一個已經存在的 Chrome 實例
(async () => {
//通過 9222 端口的 http 接口獲取對應的 websocketUrl
let version = await request({
uri: "http://127.0.0.1:9222/json/version",
json: true
});
//直接連接已經存在的 Chrome
let browser = await puppeteer.connect({
browserWSEndpoint: version.webSocketDebuggerUrl
});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
await page.close();
await browser.disconnect();
})();
這兩種方式的對比:
puppeteer.launch 每次都要重新啟動一個 Chrome 進程,啟動平均耗時 100 到 150 ms,性能欠佳
puppeteer.connect 可以實現對於同一個 Chrome 實例的共用,減少啟動關閉瀏覽器的時間消耗
puppeteer.launch 啟動時參數可以動態修改
通過 puppeteer.connect 我們可以遠程連接一個 Chrome 實例,部署在不同的機器上
puppeteer.connect 多個頁面共用一個 chrome 實例,偶爾會出現 Page Crash 現象,需要進行並發控制,並定時重啟 Chrome 實例
如何等待加載?
在實踐中我們經常會遇到如何判斷一個頁面加載完成了,什么時機去截圖,什么時機去點擊某個按鈕等問題,那我們到底如何去等待加載呢?
下面我們把等待加載的 API 分為三類進行介紹:
加載導航頁面
page.goto:打開新頁面
page.goBack :回退到上一個頁面
page.goForward :前進到下一個頁面
page.reload :重新加載頁面
page.waitForNavigation:等待頁面跳轉
Pupeeteer 中的基本上所有的操作都是異步的,以上幾個 API 都涉及到關於打開一個頁面,什么情況下才能判斷這個函數執行完畢呢,這些函數都提供了兩個參數 waitUtil 和 timeout,waitUtil 表示直到什么出現就算執行完畢,timeout 表示如果超過這個時間還沒有結束就拋出異常。
await page.goto('https://www.baidu.com', {
timeout: 30 * 1000,
waitUntil: [
'load', //等待 “load” 事件觸發
'domcontentloaded', //等待 “domcontentloaded” 事件觸發
'networkidle0', //在 500ms 內沒有任何網絡連接
'networkidle2' //在 500ms 內網絡連接個數不超過 2 個
]
});
以上 waitUtil 有四個事件,業務可以根據需求來設置其中一個或者多個觸發才以為結束,networkidle0 和 networkidle2 中的 500ms 對時間性能要求高的用戶來說,還是有點長的
等待元素、請求、響應
page.waitForXPath:等待 xPath 對應的元素出現,返回對應的 ElementHandle 實例
page.waitForSelector :等待選擇器對應的元素出現,返回對應的 ElementHandle 實例
page.waitForResponse :等待某個響應結束,返回 Response 實例
page.waitForRequest:等待某個請求出現,返回 Request 實例
await page.waitForXPath('//img');
await page.waitForSelector('#uniqueId');
await page.waitForResponse('https://d.youdata.netease.com/api/dash/hello');
await page.waitForRequest('https://d.youdata.netease.com/api/dash/hello');
自定義等待
如果上面提供的等待方式都不能滿足我們的需求,puppeteer 還提供我們提供兩個函數:
page.waitForFunction:等待在頁面中自定義函數的執行結果,返回 JsHandle 實例
page.waitFor:設置等待時間,實在沒辦法的做法
await page.goto(url, { timeout: 120000, waitUntil: 'networkidle2' });
//我們可以在頁面中定義自己認為加載完的事件,在合適的時間點我們將該事件設置為 true
//以下是我們項目在觸發截圖時的判斷邏輯,如果 renderdone 出現且為 true 那么就截圖,如果是 Object,說明頁面加載出錯了,我們可以捕獲該異常進行提示
let renderdoneHandle = await page.waitForFunction('window.renderdone', {
polling: 120
});
const renderdone = await renderdoneHandle.jsonValue();
if (typeof renderdone === 'object') {
console.log(`加載頁面失敗:報表${renderdone.componentId}出錯 -- ${renderdone.message}`);
}else{
console.log('頁面加載成功');
}
兩個獨立的環境
在使用 Puppeteer 時我們幾乎一定會遇到在這兩個環境之間交換數據:運行 Puppeteer 的 Node.js 環境和 Puppeteer 操作的頁面 Page DOM,理解這兩個環境很重要
首先 Puppeteer 提供了很多有用的函數去 Page DOM Environment 中執行代碼,這個后面會介紹到
其次 Puppeteer 提供了 ElementHandle 和 JsHandle 將 Page DOM Environment 中元素和對象封裝成對應的 Node.js 對象,這樣可以直接這些對象的封裝函數進行操作 Page DOM
10 個用例告訴你如何使用 puppeteer
下面介紹 10 個關於使用 Puppeteer 的用例,並在介紹用例的時候會穿插的講解一些 API,告訴大家如何使用 Puppeteer:
Case1:截圖
我們使用 Puppeteer 既可以對某個頁面進行截圖,也可以對頁面中的某個元素進行截圖:
(async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); //設置可視區域大小 await page.setViewport({width: 1920, height: 800}); await page.goto('https://youdata.163.com'); //對整個頁面截圖 await page.screenshot({ path: './files/capture.png', //圖片保存路徑 type: 'png', fullPage: true //邊滾動邊截圖 // clip: {x: 0, y: 0, width: 1920, height: 800} }); //對頁面某個元素截圖 let [element] = await page.$x('/html/body/section[4]/div/div[2]'); await element.screenshot({ path: './files/element.png' }); await page.close(); await browser.close(); })();
我們怎么去獲取頁面中的某個元素呢?
page.$(’#uniqueId’):獲取某個選擇器對應的第一個元素
page.$$(‘div’):獲取某個選擇器對應的所有元素
page.$x(’//img’):獲取某個 xPath 對應的所有元素
page.waitForXPath(’//img’):等待某個 xPath 對應的元素出現
page.waitForSelector(’#uniqueId’):等待某個選擇器對應的元素出現
case2: 模擬用戶登錄
(async () => { const browser = await puppeteer.launch({ slowMo: 100, //放慢速度 headless: false, defaultViewport: {width: 1440, height: 780}, ignoreHTTPSErrors: false, //忽略 https 報錯 args: ['--start-fullscreen'] //全屏打開頁面 }); const page = await browser.newPage(); await page.goto('https://demo.youdata.com'); //輸入賬號密碼 const uniqueIdElement = await page.$('#uniqueId'); await uniqueIdElement.type('admin@admin.com', {delay: 20}); const passwordElement = await page.$('#password', {delay: 20}); await passwordElement.type('123456'); //點擊確定按鈕進行登錄 let okButtonElement = await page.$('#btn-ok'); //等待頁面跳轉完成,一般點擊某個按鈕需要跳轉時,都需要等待 page.waitForNavigation() 執行完畢才表示跳轉成功 await Promise.all([ okButtonElement.click(), page.waitForNavigation() ]); console.log('admin 登錄成功'); await page.close(); await browser.close(); })();
那么 ElementHandle 都提供了哪些操作元素的函數呢?
elementHandle.click():點擊某個元素
elementHandle.tap():模擬手指觸摸點擊
elementHandle.focus():聚焦到某個元素
elementHandle.hover():鼠標 hover 到某個元素上
elementHandle.type(‘hello’):在輸入框輸入文本
case3:請求攔截
請求在有些場景下很有必要,攔截一下沒必要的請求提高性能,我們可以在監聽 Page 的 request 事件,並進行請求攔截,前提是要開啟請求攔截 page.setRequestInterception(true)。
(async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); const blockTypes = new Set(['image', 'media', 'font']); await page.setRequestInterception(true); //開啟請求攔截 page.on('request', request => { const type = request.resourceType(); const shouldBlock = blockTypes.has(type); if(shouldBlock){ //直接阻止請求 return request.abort(); }else{ //對請求重寫 return request.continue({ //可以對 url,method,postData,headers 進行覆蓋 headers: Object.assign({}, request.headers(), { 'puppeteer-test': 'true' }) }); } }); await page.goto('https://demo.youdata.com'); await page.close(); await browser.close(); })();
那 page 頁面上都提供了哪些事件呢?
page.on(‘close’) 頁面關閉
page.on(‘console’) console API 被調用
page.on(‘error’) 頁面出錯
page.on(‘load’) 頁面加載完
page.on(‘request’) 收到請求
page.on(‘requestfailed’) 請求失敗
page.on(‘requestfinished’) 請求成功
page.on(‘response’) 收到響應
page.on(‘workercreated’) 創建 webWorker
page.on(‘workerdestroyed’) 銷毀 webWorker
case4:獲取 WebSocket 響應
Puppeteer 目前沒有提供原生的用於處理 WebSocket 的 API 接口,但是我們可以通過更底層的 Chrome DevTool Protocol (CDP) 協議獲得
(async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); //創建 CDP 會話 let cdpSession = await page.target().createCDPSession(); //開啟網絡調試,監聽 Chrome DevTools Protocol 中 Network 相關事件 await cdpSession.send('Network.enable'); //監聽 webSocketFrameReceived 事件,獲取對應的數據 cdpSession.on('Network.webSocketFrameReceived', frame => { let payloadData = frame.response.payloadData; if(payloadData.includes('push:query')){ //解析payloadData,拿到服務端推送的數據 let res = JSON.parse(payloadData.match(/\{.*\}/)[0]); if(res.code !== 200){ console.log(`調用websocket接口出錯:code=${res.code},message=${res.message}`); }else{ console.log('獲取到websocket接口數據:', res.result); } } }); await page.goto('https://netease.youdata.163.com/dash/142161/reportExport?pid=700209493'); await page.waitForFunction('window.renderdone', {polling: 20}); await page.close(); await browser.close(); })();
case5:植入 javascript 代碼
Puppeteer 最強大的功能是,你可以在瀏覽器里執行任何你想要運行的 javascript 代碼,下面是我在爬 188 郵箱的收件箱用戶列表時,發現每次打開收件箱再關掉都會多處一個 iframe 來,隨着打開收件箱的增多,iframe 增多到瀏覽器卡到無法運行,所以我在爬蟲代碼里加了刪除無用 iframe 的腳本:
(async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('https://webmail.vip.188.com'); //注冊一個 Node.js 函數,在瀏覽器里運行 await page.exposeFunction('md5', text => crypto.createHash('md5').update(text).digest('hex') ); //通過 page.evaluate 在瀏覽器里執行刪除無用的 iframe 代碼 await page.evaluate(async () => { let iframes = document.getElementsByTagName('iframe'); for(let i = 3; i < iframes.length - 1; i++){ let iframe = iframes[i]; if(iframe.name.includes("frameBody")){ iframe.src = 'about:blank'; try{ iframe.contentWindow.document.write(''); iframe.contentWindow.document.clear(); }catch(e){} //把iframe從頁面移除 iframe.parentNode.removeChild(iframe); } } //在頁面中調用 Node.js 環境中的函數 const myHash = await window.md5('PUPPETEER'); console.log(`md5 of ${myString} is ${myHash}`); }); await page.close(); await browser.close(); })();
有哪些函數可以在瀏覽器環境中執行代碼呢?
page.evaluate(pageFunction[, …args]):在瀏覽器環境中執行函數
page.evaluateHandle(pageFunction[, …args]):在瀏覽器環境中執行函數,返回 JsHandle 對象
page.$$eval(selector, pageFunction[, …args]):把 selector 對應的所有元素傳入到函數並在瀏覽器環境執行
page.$eval(selector, pageFunction[, …args]):把 selector 對應的第一個元素傳入到函數在瀏覽器環境執行
page.evaluateOnNewDocument(pageFunction[, …args]):創建一個新的 Document 時在瀏覽器環境中執行,會在頁面所有腳本執行之前執行
page.exposeFunction(name, puppeteerFunction):在 window 對象上注冊一個函數,這個函數在 Node 環境中執行,有機會在瀏覽器環境中調用 Node.js 相關函數庫
case6: 如何抓取 iframe 中的元素
一個 Frame 包含了一個執行上下文(Execution Context),我們不能跨 Frame 執行函數,一個頁面中可以有多個 Frame,主要是通過 iframe 標簽嵌入的生成的。其中在頁面上的大部分函數其實是 page.mainFrame().xx 的一個簡寫,Frame 是樹狀結構,我們可以通過 frame.childFrames() 遍歷到所有的 Frame,如果想在其它 Frame 中執行函數必須獲取到對應的 Frame 才能進行相應的處理
以下是在登錄 188 郵箱時,其登錄窗口其實是嵌入的一個 iframe,以下代碼時我們在獲取 iframe 並進行登錄
(async () => { const browser = await puppeteer.launch({headless: false, slowMo: 50}); const page = await browser.newPage(); await page.goto('https://www.188.com'); //點擊使用密碼登錄 let passwordLogin = await page.waitForXPath('//*[@id="qcode"]/div/div[2]/a'); await passwordLogin.click(); for (const frame of page.mainFrame().childFrames()){ //根據 url 找到登錄頁面對應的 iframe if (frame.url().includes('passport.188.com')){ await frame.type('.dlemail', 'admin@admin.com'); await frame.type('.dlpwd', '123456'); await Promise.all([ frame.click('#dologin'), page.waitForNavigation() ]); break; } } await page.close(); await browser.close(); })();
case7: 頁面性能分析
Puppeteer 提供了對頁面性能分析的工具,目前功能還是比較弱的,只能獲取到一個頁面性能執行的數據,如何分析需要我們自己根據數據進行分析,據說在 2.0 版本會做大的改版:
一個瀏覽器同一時間只能 trace 一次
在 devTools 的 Performance 可以上傳對應的 json 文件並查看分析結果
我們可以寫腳本來解析 trace.json 中的數據做自動化分析
通過 tracing 我們獲取頁面加載速度以及腳本的執行性能
(async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.tracing.start({path: './files/trace.json'}); await page.goto('https://www.google.com'); await page.tracing.stop(); /* continue analysis from 'trace.json' */ browser.close(); })();
case8: 文件的上傳和下載
在自動化測試中,經常會遇到對於文件的上傳和下載的需求,那么在 Puppeteer 中如何實現呢?
(async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); //通過 CDP 會話設置下載路徑 await page.target().createCDPSession().send('Page.setDownloadBehavior', { behavior: 'allow', //允許所有下載請求 downloadPath: 'path/to/download' //設置下載路徑 }); //點擊按鈕觸發下載 await (await page.waitForSelector('#someButton')).click(); //等待文件出現,輪訓判斷文件是否出現 await waitForFile('path/to/download/filename'); //上傳時對應的 inputElement 必須是<input>元素 let inputElement = await page.waitForXPath('//input[@type="file"]'); await inputElement.uploadFile('/path/to/file'); browser.close(); })();
case9:跳轉新 tab 頁處理
在點擊一個按鈕跳轉到新的 Tab 頁時會新開一個頁面,這個時候我們如何獲取改頁面對應的 Page 實例呢?可以通過監聽 Browser 上的 targetcreated 事件來實現,表示有新的頁面創建:
let page = await browser.newPage(); await page.goto(url); let btn = await page.waitForSelector('#btn'); //在點擊按鈕之前,事先定義一個 Promise,用於返回新 tab 的 Page 對象 const newPagePromise = new Promise(res => browser.once('targetcreated', target => res(target.page()) ) ); await btn.click(); //點擊按鈕后,等待新tab對象 let newPage = await newPagePromise;
case10: 模擬不同的設備
Puppeteer 提供了模擬不同設備的功能,其中 puppeteer.devices 對象上定義很多設備的配置信息,這些配置信息主要包含 viewport 和 userAgent,然后通過函數 page.emulate 實現不同設備的模擬
const puppeteer = require('puppeteer');
const iPhone = puppeteer.devices['iPhone 6'];
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.emulate(iPhone);
await page.goto('https://www.google.com');
await browser.close();
});
Puppeteer vs Phantomjs
完全真實的瀏覽器操作,支持所有 Chrome 特性
可以提供不同版本的 Chrome 瀏覽器環境
Chrome 團隊維護,擁有更好的兼容性和前景
headless 參數動態配置,調試更為方便,通過 –remote-debugging-port=9222,可以進入調試界面調試
支持最新的 JS 語法,比如 async/await 等
完備的事件驅動機制,不需要太多的 sleep
Phantomjs 環境安裝復雜,API 調用不友好
兩者的主要不同在於 Phantomjs 使用了一個較老版本的 WebKit 作為它的渲染引擎
比 Phantomjs 有更快更好的性能,以下是其他人對於 Puppeteer 和 Phantomjs 性能對比結果:
Headless Chrome vs PhantomJS Benchmark
Puppeteer 在我們團隊的應用場景
性能和優化
關於共享內存:
Chrome 默認使用 /dev/shm 共享內存,但是 docker 默認/dev/shm 只有64MB,顯然是不夠使用的,提供兩種方式來解決:
- 啟動 docker 時添加參數 --shm-size=1gb 來增大 /dev/shm 共享內存,但是 swarm 目前不支持 shm-size 參數
- 啟動 Chrome 添加參數 - disable-dev-shm-usage,禁止使用 /dev/shm 共享內存
盡量使用同一個瀏覽器實例,這樣可以實現緩存共用
通過請求攔截沒必要加載的資源
像我們自己打開 Chrome 一樣,tab 頁多必然會卡,所以必須有效控制 tab 頁個數
一個 Chrome 實例啟動時間長了難免會出現內存泄漏,頁面奔潰等現象,所以定時重啟 Chrome 實例是有必要的
為了加快性能,關閉沒必要的配置,比如:-no-sandbox(沙箱功能),–disable-extensions(擴展程序)等
盡量避免使用 page.waifFor(1000),讓程序自己決定效果會更好
因為和 Chrome 實例連接時使用的 Websocket,會存在 Websocket sticky session 問題,這個需要特別注意
參考文獻
Puppeteer 指南
Puppeteer 性能優化與執行速度提升
phantomJs之殤,chrome-headless之生
Headless Chrome vs PhantomJS Benchmark
Scraping iframes with Puppeteer
————————————————
版權聲明:本文為CSDN博主「zdplife」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/zdplife/article/details/98112895
喜歡這篇文章?歡迎打賞~~

