我一直以为,爬虫是许多web开发人员难以回避的点。咱们也应该或多或少的去接触这方面,由于能够从爬虫中学习到web开发中应当掌握的一些基本知识。并且,它还颇有趣。javascript
我是一个知乎轻微重度用户,以前写了一只爬虫帮我爬取并分析它的数据,我感受这个过程仍是挺有意思,由于这是一个不断给本身创造问题又去解决问题的过程。其中遇到了一些点,今天总结一下跟你们分享分享。css
先简单介绍下个人爬虫。它可以定时抓取一个问题的关注量、浏览量、回答数,以便于我将这些数据绘成图表展示它的热点趋势。为了避免让我错过一些热门事件,它还会定时去获取我关注话题下的热门问答,并推送到个人邮箱。html
做为一个前端开发人员,我必须为这个爬虫系统作一个界面,能让我登录知乎账号,添加关注的题目、话题,看到可视化的数据。因此这只爬虫还有登录知乎、搜索题目的功能。前端
而后来看下界面。vue
下面正儿八经讲它的开发历程。java
Python得益于其简单快捷的语法、以及丰富的爬虫库,一直是爬虫开发人员的首选。惋惜我不熟。固然最重要的是,做为一名前端开发人员,node能知足爬虫需求的话,天然更是首选。并且随着node的发展,也有许多好用的爬虫库,甚至有puppeteer这样直接能模拟Chrome访问网页的工具的推出,node在爬虫方面应该是妥妥能知足我全部的爬虫需求了。node
因而我选择从零搭建一个基于koa2的服务端。为何不直接选择egg,express,thinkjs这些更加全面的框架呢?由于我爱折腾嘛。并且这也是一个学习的过程。若是之前不了解node,又对搭建node服务端有兴趣,能够看我以前的一篇文章-从零搭建Koa2 Server。react
爬虫方面我选择了request+cheerio。虽然知乎有不少地方用到了react,但得益于它绝大部分页面仍是服务端渲染,因此只要能请求网页与接口(request),解析页面(cherrio)便可知足个人爬虫需求。git
其余不一一举例了,我列个技术栈github
技术选型妥善后,咱们就要关心业务了。首要任务就是真正的爬取到页面。
知乎并无对外开放接口能让用户获取数据,因此想获取数据,就得本身去爬取网页信息。咱们知道即便是网页,它本质上也是个GET请求的接口,咱们只要在服务端去请求对应网页的地址(客户端请求会跨域),再把html结构解析下,获取想要的数据便可。
那为何我要搞一个登录呢?由于非登录账号获取信息,知乎只会展示有限的数据,并且也没法得知本身知乎账户关注的话题、问题等信息。并且如果想本身的系统也给其余朋友使用,也必须搞一个账户系统。
你们都会用Chrome等现代浏览器看请求信息,咱们在知乎的登陆页进行登录,而后查看捕获接口信息就能知道,登录无非就是向一个登录api发送帐户、密码等信息,若是成功。服务端会向客户端设置一个cookie,这个cookie便是登录凭证。
因此咱们的思路也是如此,经过爬虫服务端去请求接口,带上咱们的账号密码信息,成功后再将返回的cookie存到咱们的系统数据库,之后再去爬取其余页面时,带上此cookie便可。
固然,等咱们真正尝试时,会受到更多挫折,由于会遇到token、验证码等问题。不过,因为咱们有客户端了,能够将验证码的识别交给真正的人,而不是服务端去解析图片字符,这下降了咱们实现登录的难度。
一波三折的是,即便你把正确验证码提交了,仍是会提示验证码错误。若是咱们本身作过验证码提交的系统就可以迅速的定位缘由。若是没作过,咱们再次查看登录时涉及的请求与响应,咱们也能猜到:
在客户端获取验证码时,知乎服务端还会往客户端设置一个新cookie,提交登录请求时,必须把验证码与此cookie一同提交,来验证这次提交的验证码确实是当时给予用户的验证码。
语言描述有些绕,我以图的形式来表达一个登录请求的完整流程。
注:我编写爬虫时,知乎还部分采起图片字符验证码,现已所有改成“点击倒立文字”的形式。这样会加大提交正确验证码的难度,但也并不是机关用尽。获取图片后,由人工识别并点击倒立文字,将点击的坐标提交到登录接口便可。固然有兴趣有能力的同窗也能够本身编写算法识别验证码。
上一步中,咱们已经获取到了登录后的凭证cookie。用户登录成功后,咱们把登录的账户信息与其凭证cookie存到mongo中。之后此用户发起的爬取需求,包括对其跟踪问题的数据爬取都根据此cookie爬取。
固然cookie是有时间期限的,因此当咱们存cookie时,应该把过时时间也记录下来,当后面再获取此cookie时,多加一步过时校验,若过时了则返回过时提醒。
爬虫的基础搞定后,就能够真正去获取想要的数据了。个人需求是想知道某个知乎问题的热点趋势。先用浏览器去看看一个问题页面下都有哪些数据,能够被我爬取分析。举个例子,好比这个问题:有哪些使人拍案叫绝的推理桥段。
打开连接后,页面上最直接展示出来的有关注者,被浏览,1xxxx个回答,还要默认展现的几个高赞回答及其点赞评论数量。右键查看网站源代码,确认这些数据是服务端渲染出来的,咱们就能够经过request请求网页,再经过cherrio,使用css选择器定位到数据节点,获取并存储下来。代码示例以下:
async getData (cookie, qid) {
const options = {
url: `${zhihuRoot}/question/${qid}`,
method: 'GET',
headers: {
'Cookie': cookie,
'Accept-Encoding': 'deflate, sdch, br' // 不容许gzip,开启gzip会开启知乎客户端渲染,致使没法爬取
}
}
const rs = await this.request(options)
if (rs.error) {
return this.failRequest(rs)
}
const $ = cheerio.load(rs)
const NumberBoard = $('.NumberBoard-item .NumberBoard-value')
const $title = $('.QuestionHeader-title')
$title.find('button').remove()
return {
success: true,
title: $title.text(),
data: {
qid: qid,
followers: Number($(NumberBoard[0]).text()),
readers: Number($(NumberBoard[1]).text()),
answers: Number($('h4.List-headerText span').text().replace(' 个回答', ''))
}
}
}复制代码
这样咱们就爬取了一个问题的数据,只要咱们可以按必定时间间隔不断去执行此方法获取数据,最终咱们就能绘制出一个题目的数据曲线,分析起热点趋势。
那么问题来了,如何去作这个定时任务呢?
我使用了node-schedule作任务调度。若是以前作过定时任务的同窗,可能对其相似cron的语法比较熟悉,不熟悉也不要紧,它提供了not-cron-like的,更加直观的设置去配置任务,看下文档就能大体了解。
固然这个定时任务不是简单的不断去执行上述的爬取方法getData
。由于这个爬虫系统不只是一个用户,一个用户不只只跟踪了一个问题。
因此咱们此处的完整任务应该是遍历系统的每一个cookie未过时用户,再遍历每一个用户的跟踪问题,再去获取这些问题的数据。
系统还有另外两个定时任务,一个是定时爬取用户关注话题的热门回答,另外一个是推送这个话题热门回答给相应的用户。这两个任务跟上述任务大体流程同样,就不细讲了。
可是在咱们作定时任务时会有个细节问题,就是如何去控制爬取时的并发问题。具体举例来讲:若是爬虫请求并发过高,知乎多是会限制此IP的访问的,因此咱们须要让爬虫请求一个一个的,或者若干个若干个的进行。
简单思考下,咱们会采起循环await。我不假思索的写下了以下代码:
// 爬虫方法
async function getQuestionData () {
// do spider action
}
// questions为获取到的关注问答
questions.forEach(await getQuestionData)复制代码
然而执行以后,咱们会发现这样其实仍是并发执行的,为何呢?其实仔细想下就明白了。forEach只是循环的语法糖,若是没有这个方法,让你来实现它,你会怎么写呢?你大概也写的出来:
Array.prototype.forEach = function (callback) {
for (let i = 0; i < this.length; i++) {
callback(this[i], i, this)
}
}复制代码
虽然forEach
自己会更复杂点,但大体就是这样吧。这时候咱们把一个异步方法做为参数callback
传递进去,而后循环执行它,这个执行依旧是并发执行,并不是是同步的。
因此咱们若是想实现真正的同步请求,仍是须要用for循环去执行,以下:
async function getQuestionData () {
// do spider action
}
for (let i = 0; i < questions.length; i++) {
await getQuestionData()
}复制代码
除了for循环,还能够经过for-of,若是对这方面感兴趣,能够去多了解下数组遍历的几个方法,顺便研究下ES6的迭代器Iterator
。
其实若是业务量大,即便这样作也是不够的。还须要更加细分任务颗粒度,甚至要加代理IP来分散请求。
下面说的点跟爬虫自己没有太大关系了,属于服务端架构的一些分享,若是只关心爬虫自己的话,能够不用再往下阅读了。
咱们把爬虫功能都写的差很少了,后面只要编写相应的路由,能让前端访问到数据就行了。可是编写一个没那么差劲的服务端,仍是须要咱们深思熟虑的。
我看过一些前端同窗写的node服务,常常就会把系统全部的接口(router action)都写到一个文件中,好一点的会根据模块分几个对于文件。
可是若是咱们接触过其余成熟的后端框架、或者大学学过一些J2EE等知识,就会本能意识的进行一些分层:
model
数据层。负责数据持久化,通俗说就是链接数据库,对应数据库表的实体数据模型;service
业务逻辑层。顾名思义,就是负责实现各类业务逻辑。controller
控制器。调取业务逻辑服务,实现数据传递,返回客户端视图或数据。固然也有些框架或者人会将业务逻辑service
实如今controller
中,亦或者是model
层中。我我的认为一个稍微复杂的项目,应该是单独抽离出抽象的业务逻辑的。
好比在我这个爬虫系统中,我将数据库的添删改查操做按model
层对应抽离出service
,另外再将爬取页面的服务、邮件推送的服务、用户鉴权的服务抽离到对应的service
。
最终咱们的api
可以设计的更加易读,整个系统也更加易拓展。
若是是直接使用一个成熟的后端框架,分层这事咱们是不用多想的。node这样的框架也有,我以前介绍的我厂开源的api-mocker采用的egg.js,也帮咱们作好了合理的分层。
可是若是本身基于koa从零搭建一个服务端,在这方面上就会遇到一些挫折。koa自己逻辑很是简单,就是调取一系列中间件(就是一个个function),来处理请求。官方本身提供的koa-router,便是帮助咱们识别请求路径,而后加载对应的接口方法。
咱们为了区分业务模块,会把一些接口方法写在同一个controller
中,好比个人questionController负责处理问题相关的接口;topicController负责处理话题相关的接口。
那么咱们可能会这样编写路由文件:
const Router = require('koa-router')
const router = new Router()
const question = require('./controller/question')
const topic = require('./controller/topic')
router.post('/api/question', question.create)
router.get('/api/question', question.get)
router.get('/api/topic', topic.get)
router.post('/api/topic/follow', topic.follow)
module.exports = router复制代码
个人question文件多是这样写的:
class Question {
async get () {
// return data
}
async create () {
// create question and return data
}
}
module.exports = new Question()复制代码
单纯这样写是没有办法真正的以面向对象的形式来编写controller
的。为何呢?
由于咱们将question对象的属性方法做为中间件传递到了koa-router
中,而后由koa
底层来合并这些中间件方法,做为参数传递到http.createServer
方法中,最终由node底层监听请求时调用。那这个this
到底会是谁,不进行调试,或者查看koa与node源代码,是无从得知的。可是不管如何方法调用者确定不是这个对象自身了(实际上它会是undefined
)。
也就是说,咱们不能经过this
来获取对象自身的属性或方法。
那怎么办呢?有的同窗可能会选择将自身一些公共方法,直接写在class
外部,或者写在某个utils
文件中,而后在接口方法中使用。好比这样:
const error = require('utils/error')
const success = (ctx, data) => {
ctx.body = {
success: true,
data: data
}
}
class Question {
async get () {
success(data)
}
async create () {
error(result)
}
}
module.exports = new Question()复制代码
这样确实ok,可是又会有新的问题---这些方法就不是对象本身的属性,也就没办法被子类继承了。
为何须要继承呢?由于有时候咱们但愿一些不一样的controller
有着公共的方法或属性,举个例子:我但愿我全部的成功or失败都是这样的格式:
{
success: false,
message: '对应的错误消息'
}
{
success: true,
data: '对应的数据'
}复制代码
按照koa
的核心思想,这个通用的格式转化,应该是专门编写一个中间件,在路由中间件以后(即执行完controller里的方法以后)去作专门处理并response。
然而这样会致使每有一个公共方法,就必需要加一个中间件。并且controller
自己已经失去了对这些方法的控制权。这个中间件是执行自身仍是直接next()
将会很是难判断。
若是是抽离成utils
方法再引用,也不是不能够,就是方法多的话,声明引用稍微麻烦些,并且没有抽象类的意义。
更理想的状态应该是如刚才所说的,你们都继承一个抽象的父类,而后去调用父类的公共相应方法便可,如:
class AbstractController {
success (ctx, data) {
ctx.body = {
success: true,
data: data
}
}
error (ctx, error) {
ctx.body = {
success: false,
msg: error
}
}
}
class Question extends AbstractController {
async get (ctx) {
const data = await getData(ctx.params.id)
return super.success(ctx, data)
}
}复制代码
这样就方便多了,不过若是写过koa的人可能会有这样的烦恼,一个上下文ctx
老是要做为参数传递来传递去。好比上述控制器的全部中间件方法都得传ctx
参数,调用父类方法时,又要传它,还会使得方法损失一些可读性。
因此总结一下,咱们有以下问题:
controller
中的方法没法调用自身的其余方法、属性;ctx
。其实解决的办法很简单,咱们只要想办法让controller
方法中的this指向实例化对象自身,再把ctx
挂在到这个this
上便可。
怎么作呢?咱们只要再封装一下koa-router
就行了,以下所示:
const Router = require('koa-router')
const router = new Router()
const question = require('./controller/question')
const topic = require('./controller/topic')
const routerMap = [
['post', '/api/question', question, 'create'],
['get', '/api/question', question, 'get'],
['get', '/api/topic', topic, 'get'],
['post', '/api/topic/follow', topic, 'follow']
]
routerMap.map(route => {
const [ method, path, controller, action ] = route
router[method](path, async (ctx, next) =>
controller[action].bind(Object.assign(controller, { ctx }))(ctx, next)
)
})
module.exports = router复制代码
大意就是在路由传递controller
方法时,将controller
自身与ctx
合并,经过bind
指定该方法的this
。这样咱们就能经过this
获取方法所属controller
对象的其余方法。此外子类方法与父类方法也能经过this.ctx
来获取上下文对象ctx
。
可是bind
以前咱们其实应该考虑如下,其余中间件以及koa自己会不会也干了相似的事,修改了this的值。如何判断呢,两个办法:
bind
以前,在中间件方法中打印一下this
,是undefined
的话天然就没被绑定。事实是,天然是没有的。那咱们就放心的bind
吧。
上述大概就是编写这个小工具时,遇到的一些点,感受能够总结的。也并无什么技术难点,不过能够借此学习学习一些相关的知识,包括网站安全、爬与反爬、、koa底层原理等等。
这个工具自己很是的我的色彩,不必定知足你们的须要。并且它在半年前就写好了,只不过最近被我挖坟拿出来总结。并且就在我即将写完文章时,我发现知乎提示个人帐号不安全了。我估计是觉得同一IP同一帐户发起过多的网络请求,我这台服务器IP已经被认为是不安全的IP了,在这上面登陆的帐户都会被提示不安全。因此我不建议你们将其直接拿来使用。
固然,若是仍是对其感兴趣,本地测试下或者学习使用,仍是没什么大问题的。或者还有更深的兴趣的话,能够本身尝试去绕开知乎的安全策略。
最后的最后附上 项目GitHub地址
--阅读原文
--转载请先通过本人受权。