puppeteer+mysql—爬蟲新方法!抓取新聞&評論so easy!

圓兒圈圈發表於2018-09-17

Puppeteer 是 Google Chrome 團隊官方的無介面(Headless)Chrome 工具。正因為這個官方宣告,許多業內自動化測試庫都已經停止維護,包括 PhantomJS。Selenium IDE for Firefox 專案也因為缺乏維護者而終止。

Summary

本文將使用Chrome Headless,Puppeteer,Node和Mysql,爬取新浪微博。登入並爬取人民日報主頁的新聞,並儲存在Mysql資料庫中。

安裝

安裝Puppeteer會有一定機率因為無法下載Chromium驅動包而失敗。在上一篇文章中有介紹過Puppeteer安裝解決方案,本文就不多做介紹了。puppetter安裝就踩坑-解決篇

上手

我們先從擷取頁面開始,瞭解Puppeteer啟動瀏覽器並完成工作的一些api。

screenshot.js

const puppeteer = require('puppeteer');

(async () => {
    const pathToExtension = require('path').join(__dirname, '../chrome-mac/Chromium.app/Contents/MacOS/Chromium');
    const browser = await puppeteer.launch({
        headless: false,
        executablePath: pathToExtension
    });
    const page = await browser.newPage();
    await page.setViewport({width: 1000, height: 500});
    await page.goto('https://weibo.com/rmrb');
    await page.waitForNavigation();
    await page.screenshot({path: 'rmrb.png'});
    await browser.close();
})();
複製程式碼
  • puppeteer.launch(當 Puppeteer 連線到一個 Chromium 例項的時候會通過 puppeteer.launchpuppeteer.connect 建立一個 Browser 物件。)
    • executablePath:啟動Chromium 或者 Chrome的路徑。
    • headless:是否以headless形式啟動瀏覽器。(Headless Chrome指在headless模式下執行谷歌瀏覽器。用於自動化測試和不需要視覺化使用者介面的伺服器)
  • browser.newPage 新開頁面並返回一個Promise。Puppeteer api中大部分方法會返回Promise物件,我們需要async+await配合使用。

執行程式碼

$ node screenshot.js
複製程式碼

截圖會被儲存至根目錄下

puppeteer+mysql—爬蟲新方法!抓取新聞&評論so easy!

分析頁面結構並提取新聞

我們的目的是拿到人民日報發的微博文字和日期。

  • 新聞節點dom:div[action-type=feed_list_item]
  • 新聞內容在dom:div[action-type=feed_list_item]>.WB_detail>.WB_text
  • 新聞釋出時間dom:div[action-type=feed_list_item]>.WB_detail>.WB_from a").eq(0).attr("date")

Puppeteer提供了頁面元素提取方法:Page.evaluate。因為它作用於瀏覽器執行的上下文環境內。當我們載入好頁面後,使用 Page.evaluate 方法可以用來分析dom節點

page.evaluate(pageFunction, ...args)

  • pageFunction <[function]|[string]> 要在頁面例項上下文中執行的方法
  • ...args<...[Serializable]|[JSHandle]> 要傳給 pageFunction 的引數
  • 返回: <[Promise]<[Serializable]>> pageFunction執行的結果

如果pageFunction返回的是[Promise],page.evaluate將等待promise完成,並返回其返回值。

如果pageFunction返回的是不能序列化的值,將返回undefined

分析微博頁面資訊的程式碼如下:

const LIST_SELECTOR = 'div[action-type=feed_list_item]'
return await page.evaluate((infoDiv)=> {
    return Array.prototype.slice.apply(document.querySelectorAll(infoDiv))
        .map($userListItem => {
            var weiboDiv = $($userListItem)
            var webUrl = 'http://weibo.com'
            var weiboInfo = {
                "tbinfo": weiboDiv.attr("tbinfo"),
                "mid": weiboDiv.attr("mid"),
                "isforward": weiboDiv.attr("isforward"),
                "minfo": weiboDiv.attr("minfo"),
                "omid": weiboDiv.attr("omid"),
                "text": weiboDiv.find(".WB_detail>.WB_text").text().trim(),
                'link': webUrl.concat(weiboDiv.find(".WB_detail>.WB_from a").eq(0).attr("href")),
                "sendAt": weiboDiv.find(".WB_detail>.WB_from a").eq(0).attr("date")
            };

            if (weiboInfo.isforward) {
                var forward = weiboDiv.find("div[node-type=feed_list_forwardContent]");
                if (forward.length > 0) {
                    var forwardUser = forward.find("a[node-type=feed_list_originNick]");
                    var userCard = forwardUser.attr("usercard");
                    weiboInfo.forward = {
                        name: forwardUser.attr("nick-name"),
                        id: userCard ? userCard.split("=")[1] : "error",
                        text: forward.find(".WB_text").text().trim(),
                        "sendAt": weiboDiv.find(".WB_detail>.WB_from a").eq(0).attr("date")
                    };
                }
            }
            return weiboInfo
        })
}, LIST_SELECTOR)

複製程式碼

我們將新聞塊 LIST_SELECTOR 作為引數傳入page.evaluate,在pageFunction函式的頁面例項上下中可以使用document方法操作dom節點。 遍歷新聞塊div,分析dom結構,拿到對應的資訊。

引申一下~

因為我覺得用原生JS方法操作dom節點不習慣(jQuery慣出的低能就是我,對jQuery極度依賴...2333),所以我決定讓開發環境支援jQuery。

方法一

page.addScriptTag(options)

注入一個指定src(url)或者程式碼(content)的 script 標籤到當前頁面。

  • options <[Object]>
    • url <[string]> 要新增的script的src path <[string]> 要注入frame的js檔案路徑. 如果 path 是相對路徑, 那麼相對 當前路徑 解析。
    • content <[string]> 要注入頁面的js程式碼(即) type <[string]> 指令碼型別。 如果要注入 ES6 module,值為'module'。點選 script 檢視詳情。
    • 返回: <[Promise]<[ElementHandle]>> Promise物件,即注入完成的tag標籤。當 script 的 onload 觸發或者程式碼被注入到 frame。

所以我們直接在程式碼裡新增:

await page.addScriptTag({url: 'https://code.jquery.com/jquery-3.2.1.min.js'})
複製程式碼

然後就可以愉快得飛起了!

方法二

如果訪問的網頁本來就支援jQuery,那就更方便了!

await page.evaluate(()=> {
    var $ = window.$
})
複製程式碼

直接pageFunction中聲名變數並用 window中的$賦值就好了。

引申結束~

注意:pageFunctin中存在頁面例項,如果在程式其他地方使用document或者jquery等方法,會提示需要document環境或者直接報錯

(node:3346) UnhandledPromiseRejectionWarning: ReferenceError: document is not defined
複製程式碼

提取每條新聞的評論

光抓取新聞還不夠,我們還需要每條新聞的熱門評論。

我們發現,點選操作欄的評論按鈕後,會去載入新聞的評論。

puppeteer+mysql—爬蟲新方法!抓取新聞&評論so easy!

  1. 我們在分析完新聞塊dom元素後,模擬點選評論按鈕
$('.WB_handle span[node-type=comment_btn_text]').each(async(i, v)=>{
    $(v).trigger('click')
})
複製程式碼
  1. 使用監聽事件event: 'response',監聽頁面請求

event: 'response'

  • <[Response]>

當頁面的某個請求接收到對應的 [response] 時觸發。

如圖:當我們點選了評論按鈕,瀏覽器會傳送很多請求,我們的目的是抽取出comment請求。

puppeteer+mysql—爬蟲新方法!抓取新聞&評論so easy!

我們需要用到class Response中的幾個方法,監聽瀏覽器的的響應並分析並將評論提取出來。

  • response.url() Contains the URL of the response.
  • response.text()
    • returns: <Promise> Promise which resolves to a text representation of response body.
page.on('response', async(res)=> {
    const url = res.url()
    if (url.indexOf('small') > -1) {
        let text = await res.text()
        var mid = getQueryVariable(res.url(), 'mid');
        var delHtml = delHtmlTag(JSON.parse(text).data.html)
        var matchReg = /\:.*?(?= )/gi;
        var matchRes = delHtml.match(matchReg)
        if (matchRes && matchRes.length) {
            let comment = []
            matchRes.map((v)=> {
                comment.push({mid, content: JSON.stringify(v.split(':')[1])})
            })
            pool.getConnection(function (err, connection) {
                save.comment({"connection": connection, "res": comment}, function () {
                    console.log('insert success')
                })
            })
        }
    }
})
複製程式碼
  1. res.url()獲取到響應的url,判斷string中是否含有small關鍵字。
  2. 擷取url中的key:mid,mid用來區分評論屬於哪一條新聞。
  3. res.text()獲取響應的body,並去除body.data中dom的html標籤。
  4. 在純文字中 提取出我們需要的評論內容。

儲存到Mysql

使用Mysql儲存新聞和評論

$ npm i mysql -D mysql
複製程式碼

我們使用的mysql是一個node.js驅動的庫。它是用JavaScript編寫的,不需要編譯。

  1. 新建config.js,建立本地資料庫連線,並把配置匯出。

config.js

var mysql = require('mysql');

var ip = 'http://127.0.0.1:3000';
var host = 'localhost';
var pool = mysql.createPool({
    host:'127.0.0.1',
    user:'root',
    password:'xxxx',
    database:'yuan_place',
    connectTimeout:30000
});

module.exports = {
    ip    : ip,
    pool  : pool,
    host  : host,
}
複製程式碼
  1. 在爬蟲程式中引入config.js
page.on('response', async(res)=> {
    ...
    if (matchRes && matchRes.length) {
        let comment = []
        matchRes.map((v)=> {
            comment.push({mid, content: JSON.stringify(v.split(':')[1])})
        })
        pool.getConnection(function (err, connection) {
            save.comment({"connection": connection, "res": comment}, function () {
                console.log('insert success')
            })
        })
    }
    ...
})
const content = await getWeibo(page)

pool.getConnection(function (err, connection) {
    save.content({"connection": connection, "res": content}, function () {
        console.log('insert success')
    })
})
複製程式碼
  1. 然後我們寫一個save.js專門處理資料插入邏輯。

兩個表的結構如下:

puppeteer+mysql—爬蟲新方法!抓取新聞&評論so easy!

現在我們可以開始愉快得往資料庫塞資料了。

save.js

exports.content = function(list,callback){
    console.log('save news')
    var connection = list.connection
    async.forEach(list.res,function(item,cb){
        debug('save news',JSON.stringify(item));
        var data = [item.tbinfo,item.mid,item.isforward,item.minfo,item.omid,item.text,new Date(parseInt(item.sendAt)),item.cid,item.clink]
        if(item.forward){
            var fo = item.forward
            data = data.concat([fo.name,fo.id,fo.text,new Date(parseInt(fo.sendAt))])
        }else{
            data = data.concat(['','','',new Date()])
        }
        connection.query('select * from sina_content where mid = ?',[item.mid],function (err,res) {
            if(err){
                console.log(err)
            }
            if(res && res.length){
                //console.log('has news')
                cb();
            }else{
                connection.query('insert into sina_content(tbinfo,mid,isforward,minfo,omid,text,sendAt,cid,clink,fname,fid,ftext,fsendAt) values(?,?,?,?,?,?,?,?,?,?,?,?,?)',data,function(err,result){
                    if(err){
                        console.log('kNewscom',err)
                    }
                    cb();
                })
            }
        })
    },callback);
}
//把文章列表存入資料庫
exports.comment = function(list,callback){
    console.log('save comment')
    var connection = list.connection
    async.forEach(list.res,function(item,cb){
        debug('save comment',JSON.stringify(item));
        var data = [item.mid,item.content]
        connection.query('select * from sina_comment where mid = ?',[item.mid],function (err,res) {
            if(res &&res.length){
                cb();
            }else{
                connection.query('insert into sina_comment(mid,content) values(?,?)',data,function(err,result){
                    if(err){
                        console.log(item.mid,item.content,item)
                        console.log('comment',err)
                    }
                    cb();
                });
            }
        })
    },callback);
}
複製程式碼

執行程式,就會發現資料已經在庫裡了。

對專案無用且麻煩的進階:模擬登入

到這裡不用登入,已經可以愉快得爬新聞和評論了。但是!追求進步的我們怎麼能就此停住。做一些對專案無用的登入小元件吧!需要就引入,不需要就保持原樣。

在專案根目錄新增一個 creds.js 檔案。

module.exports = {
  username: '<GITHUB_USERNAME>',
  password: '<GITHUB_PASSWORD>'
};
複製程式碼
  1. 使用page.click模擬頁面點選

page.click(selector[, options])

  • selector A selector to search for element to click. If there are multiple elements satisfying the selector, the first will be clicked.
  • options
    • button left, right, or middle, defaults to left.
    • clickCount defaults to 1. See UIEvent.detail.
    • delay Time to wait between mousedown and mouseup in milliseconds. Defaults to 0.
    • returns: Promise which resolves when the element matching selector is successfully clicked. The Promise will be rejected if there is no element matching selector.

    因為page.click返回的是Promise,所以用await暫停。

    await page.click('.gn_login_list li a[node-type="loginBtn"]');
    複製程式碼

    puppeteer+mysql—爬蟲新方法!抓取新聞&評論so easy!

    1. await page.waitFor(2000) 等待2s,讓輸入框顯示出來。
      puppeteer+mysql—爬蟲新方法!抓取新聞&評論so easy!
    2. 使用page.type輸入使用者名稱、密碼(這裡我們為了模擬使用者輸入的速度,加了{delay:30}引數,可以根據實際情況修改),再模擬點選登入按鈕,使用page.waitForNavigation()等待頁面登入成功後的跳轉。
    await page.type('input[name=username]',CREDS.username,{delay:30});
    await page.type('input[name=password]',CREDS.password,{delay:30});
    await page.click('.item_btn a');
    await page.waitForNavigation();
    複製程式碼

    因為我使用的測試賬號沒有繫結手機號,所以用以上的方法可以完成登入。如果繫結了手機號的小夥伴,需要用客戶端掃描二次認證。

    最後

    爬蟲效果圖:

    puppeteer+mysql—爬蟲新方法!抓取新聞&評論so easy!

    爬蟲的demo在這裡:github.com/wallaceyuan…

    參考文件:

    github.com/GoogleChrom… github.com/GoogleChrom…

    覺得好玩就關注一下~ 歡迎大家收藏寫評論~~~

相關文章