1. 前言
这篇文章藏在心中已经好一段时日了,迟迟不敢动笔,主要是担心不知道该如何去组织这样一篇技术文章。
其实个人感觉技术性的文章是最难写的,细节往往很难拿捏。有些技术细节没有解释到位,害怕读的人难以理解。反之,有些简单的东西怕讲解太多,增加了不必要的篇章。
授人以鱼不如授人以渔
故而,这篇文章我会尽量少贴代码,多谈思考过程。
2. 编码的缘由
我最近在重新搭建自己的个人网站,想把自己在简书写的所有文章数据都导入到那个网站上。
这个时候有些朋友可能会不乐意了:“如果要获取自己所写的所有文章为什么要写代码?简书不是已经提供了下载当前用户所有文章的功能了吗?“
兄弟说得在理,首先我得声明简书确实是一个很棒的写作平台,他能够满足绝大多数用户的需求,但是对于我这种“懒”癌晚期的程序员来说却还是有点不太够。我有以下几点考虑
- 把几十篇文章一篇篇复制粘贴到个人网站的后台,我想我会疯掉的(我现在有68篇文章)。
- 当简书的文章需要更新的时候,我不得不再去自己的网站后台手动更新对应文章,这个工作有点重复了。
- 简书下载的文章并没有提供对应文章的
发布时间
,所属文集
,字数
这些元数据,我并不想手动为每一篇文章设置对应的元数据值。
秉持着
Don't Repeat Yourself
这个原则,让我们开始这次爬虫之旅吧。
3. 功能介绍
简单概括一下爬虫的功能
- 向服务端发送请求获取作者的所有文章。
- 从文章中提取出自己需要的数据(文章内容,标题,发布日期......)
- 组织数据,存储到对应的数据库中。
本文重点会放在页面的请求,解析以及数据的组织上,至于如何把数据存进数据库,不会多加讲解。有兴趣的可以直接看源码。
4. 技术栈
我最后选择使用node.js
来写这个爬虫,毕竟要重新去学习python或者ruby这些服务端语言需要耗费我不少时间。只是这样当然这样还不够。
工欲善其事,必先利其器
为了少写些代码我还需要一个较为成熟的爬虫框架。这里使用的是node-crawler。个人觉得这是一个比较cool的爬虫框架,而且前端人员用起来定会觉得倍感亲切-我们可以用我们最亲切的jQuery语法来解析响应返回的页面。
5. 数据模型
在要爬一样东西之前,首先我们肯定得确认要爬取的东西是什么。这里为了简化文章,我把需要爬取的内容定为以下三个字段(源码里面可不止这几个字段)。
- 文章标题
- 文章发布日期
- 文章内容(markdown格式)
我只需要想办法从响应返回的页面里提取出上面三种数据就可以了。至于最后把数据存到什么数据库里面,怎么存,那便看个人喜好了。
6. 制定爬虫策略
(1)基本信息爬取
首先进入对应作者的简书主页,类似这个页面。我们会看到有一堆文章列表,看看能不能提取到我们需要的信息?
好吧,一大堆问题,除了文章标题之外其他内容似乎都难以爬取。不管怎么样都得去文章详情页看看了
看来详情页面还算能够满足我的需求。故而,我决定基本信息的提取采用如下策略
- 通过爬虫爬取用户首页的所有文章条目,提取出每一篇文章对应详情页的链接。
- 通过爬虫框架发送请求,分别请求每一篇文章的详情页面。
- 解析详情页面的内容,提取出我们需要的信息。
(2)面对滚动加载,该如何获得所有文章?
在进入特定作者的简书主页的时候,我发现其实简书并没有加载该作者所有的文章,他只是加载了文章列表的一部分。如果需要获取更多的文章列表,需要向下滚动,向服务器端请求更多的页面。
刚开始我是站在前端的立场上去考虑这个问题,真的很蛋疼。我最初的想法是使用一些库如phantomjs去模拟浏览器的行为。我打算模拟浏览器滚动行为,等数据加载完成之后再继续滚动,直到不再往服务端请求数据为止。我就真的这么做了,后来才发现这是个噩梦,这意味着我得做下面的事情:
- 加载页面。
- 模拟浏览器滚动行为加载更多文章数据。
- 监听请求行为,请求完成之后再继续滚动。
- 判断什么时候滚到最底部。
先不说有没有程序库支持上面的功能,以上策略明眼人一看就觉得不靠谱,估计也就只有我傻乎乎地跑去尝试了。我多次尝试后发现,要模拟滚动行为都相当困难,而且使用phantomjs
的时候开发环境经常会卡死。直觉告诉我或许还有更好的实现方式。当对一个问题百思不得其解的时候,或许可以尝试跳出原来的思维定势,从另一个角度去考虑这个问题。问题往往会简单不少。
后来我从后端的角度上去考虑这个事情
问: 加载更多这个行为的本质是什么?
答: 向服务端发送请求,获取更多页面数据。
那我只需要知道页面滚动的时候浏览器向服务端发送请求的url以及对应的参数,我不就可以用爬虫来迭代发送这个请求,进而达到获取完整的文章列表的目的了吗?
OK,马上去Network
看看,它可以看到我们发送的网络请求。我在Network
下清除历史记录后滚动页面,有以下发现。
服务端响应后返回的是渲染过的列表数据。果然跟我预料的一样。然后我再看看完整的url
原来它是使用了分页参数page
,来向服务端请求分页数据。我通过代码来封装这个过程
var i = 1;
var queue = []
while( i < 10) {
var uriObject = {
uri: 'http://www.jianshu.com/u/a8522ac98584?order_by=shared_at&&page=' + i,
queue.push(uriObject);
i ++;
}
这只是代码片段的一部分,把10个url存进队列数组里。最终也只能获取10页的数据,不过这就有个问题了如果真实数据不到10页,那该如何处理?
我试着请求第100页的数据,看看会发生什么。发现简书的服务端返回了一个302
的状态码,然后浏览器跳转到个人动态的页面去了。
这个状态码很有用,我可以针对这个状态码判断我们的请求的页码参数page
的值有没有超出指定的页数。
我可以预设更多的请求,如果请求返回这个状态码,则不对请求的数据进行任何处理(因为已经超出页数的范围)。反之则对返回的数据进行解析,提取出我们需要的关键数据。
当然这种做法相当粗暴,会发送许多不必要的请求。接下来有时间我会对这部分代码进行优化。
7. 页面解析
请求发送完了,等服务端响应之后我们便可获取到我们需要的页面了。接下来要做的就是对页面结果进行解析,提取出我们需要的内容。上面也说过了 node-crawler
这个爬虫框架内置了jQuery
,这让我们页面解析工作变得简单。
(1)获取文章详情页面的url
先来看看文章列表每个条目的html结构(简书的程序员也做了注释)。
我们只需要提取出 ul.note-list
里面的每一条li a.title
,然后再提取出它们的href
属性对应的值,便是我们需要获取的url了。这种操作对jQuery
来说简直易如反掌。下面是我的代码片段,我把所有url提出来后都存储在articlesLink
这个数组里面,供后面的程序使用。
let articlesLink = [];
$('ul.note-list').find('li').each((i, item) => {
var $article = $(item);
let link = $article.find('.title').attr('href');
articlesLink.push(link);
})
(2)从详情页面中提取数据
再look look详情页面的结构
从页面结构看,我们可以很简单地提取出标题,还有发布日期这两个字段的内容
let title = $article.find('.title').text();
let date = $article.find('.publish-time').text().replace('*', '');
有些更新过的文章,发布日期最后就会有个*号,避免干扰我需要把他们都处理掉。但是文章主体的提取就有一些问题了。
我最后期望得到的是markdown格式的字符串。这点我可以通过 to-markdown这个package把html转换成markdown。但是现在问题是这个包似乎是无法解析div
这个标签的。我考虑着把文章主体里面的所有div
标签都删掉,然后把处理过的字符串通过to-markdown转换成对应的markdown格式的字符串,便可以得到我们期望的数据了。
既然有了jQuery
这个神器,实现起来不会很麻烦。不过我还想要删除含有类名image-caption
的标签-这个是简书默认设置,有的时候它有点碍事,可以考虑删掉。
以下是我的代码片段:
var toMarkdown = require('to-markdown');
// 删除图片的标题
let $content = $article.find('.show-content');
$content.find('.image-caption').remove();
$content.find('div').each(function(i, item) {
var children = $(this).html();
$(this).replaceWith(children);
})
// 获取markdown格式的文章
let articleBody = toMarkdown($content.html());
最后,只需要把他们放到对象里面:
let article;
article = {
title: title,
date: new Date(date),
articleBody: articleBody
}
至于如何把上面的数据存入数据库,方法有很多,考虑到文章篇幅问题,这里就不多做叙述了。个人比较推崇MongoDB。它是一款目前用得最多的非关系型数据库,灵活性很强。对于构建渐进式的应用,我觉得这是一个比较好的选择。在经常修改的表结构的情景下,起码你不用去维护一大堆的migrations
文件。
最后入库结果如下,刚好是68篇文章
结尾
不知不觉这篇文章已经占去了好几个小时,即便这篇文章我已经去除了不必要的代码细节,着重对自己的思考过程以及遇到的问题进行了总结,但还是写了不少字。目测总结能力还有待提高啊~~~~
注意:本文只是在总结一些经验以及思考过程,github的源代码也只是起参考作用,并不是即插即用的程序包。您可以根据您的情况来写出满足自己需求的爬虫,相信你能做得比我更好。
网友评论
Happy Coding and Writing !!