阅读 3135

puppeteer+mysql—爬虫新方法!抓取新闻&评论so easy!

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
复制代码

截图会被保存至根目录下

分析页面结构并提取新闻

我们的目的是拿到人民日报发的微博文字和日期。

  • 新闻节点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
复制代码

提取每条新闻的评论

光抓取新闻还不够,我们还需要每条新闻的热门评论。

我们发现,点击操作栏的评论按钮后,会去加载新闻的评论。

  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请求。

我们需要用到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专门处理数据插入逻辑。

两个表的结构如下:

现在我们可以开始愉快得往数据库塞数据了。

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"]');
    复制代码

    1. await page.waitFor(2000) 等待2s,让输入框显示出来。
    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();
    复制代码

    因为我使用的测试账号没有绑定手机号,所以用以上的方法可以完成登录。如果绑定了手机号的小伙伴,需要用客户端扫描二次认证。

    最后

    爬虫效果图:

    爬虫的demo在这里:github.com/wallaceyuan…

    参考文档:

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

    觉得好玩就关注一下~ 欢迎大家收藏写评论~~~

关注下面的标签,发现更多相似文章
评论