結合項目來談談 Puppeteer


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 地址

什么是 Headless Chrome

  • 在無界面的環境中運行 Chrome
  • 通過命令行或者程序語言操作 Chrome
  • 無需人的干預,運行更穩定
  • 在啟動 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 會話設置下載路徑  const cdp = await page.target().createCDPSession(); await cdp.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 問題,這個需要特別注意

參考文獻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM