2018 年 6 月 6 日 15:35:00
一年多前在慕课网手记里发布了篇 《nodejs 爬虫——知乎专栏》,并没有详细的说明这个爬虫的全部工作原理,只是贴了一份代码。经过了一年,原来的代码已经做了很多优化,并使用了知乎的 api 模块,后来发现 api 模块的作者很久没有维护导致API不可以,所以自己手动维护了知乎专栏的一小部分,不过并没PR(作者issues都不回复了,PR也没人理的)。
使用到的模块由于知乎专栏现在使用的是API,抓取直接请求API就好,获取到数据,把数据转成markdown格式存在本地,方便以后看。所以使用的模块就三个。
"lodash"
"mkdirp"
"request"
"turndown"
抓取原理
在编写代码前,需要先对整个逻辑有个大概的思路:
- 使用
request
请求知乎专栏的数据 - 使用
turndown
把html内容转为markdown格式 - 保存为本地文件
原理是很简单,但是实际操作起来并没有这么容易。在写代码的过程中,就会发现各种各样的问题:
- 知乎专栏的api每次只能获取每次20条的数据。
turndown
处理代码块的标签嵌套和知乎API返回的不一样。- 要是保存到一个并不存在的文件夹下,NodeJS的
fs
模块并不支持自动创建文件夹。
基于这几个问题,我们需要另外写对应的方法来解决问题。
- 编写对应的方法,输入知乎专栏的文章数量,返回一个包含所有文章URL的数组,遍历这个数组即可得到获取到所有的文章。
- 使用正则把代码块的标签嵌套替换成
turndown
能识别的格式,方便得到我们想要的MarkDown格式的文档。 - 文件路径不存在就创建路径。
经过合理的封装,最顶部的代码就5行:
async function zhuanlan(postID, localPath = './') {
console.log(`----- ${postID} start -----`);
mkdir(path.resolve(localPath, postID));
markdown(localPath, postID, await Posts(postID));
};
实际上正真起作用的代码就两行,创建文件夹,将专栏API返回的所有数据转成markdown,并储存。
代码解析上面的代码可以分成三个大模块,分别是mkdir
,Posts
和MarkDown
。
mkdir 创建文件夹
因为fs的mkdir方法只能创建一层的文件夹,如果想创建更深层的文件夹,还是使用已经封装好的mkdirp
模块。
function mkdir(filePath) {
if (fs.existsSync(`${filePath}`)) {
console.log(` ${path.basename(filePath)} 文件夹已经存在`);
} else {
mkdirp(`${filePath}`, (err) => {
if (err) {
console.error(err);
} else {
console.log(`? 创建 ${path.basename(filePath)}文件夹成功`);
}
});
}
}
文件夹是存放markdown文档的基础,只有先创建好文件夹,才能保证爬虫抓取的数据有用处。要不然爬了也是白爬取。
Posts 抓取逻辑
为什么这个方法叫Posts
,因为知乎的专栏文章的API就使用这个为路径。
在这个方法里,实现了将所有的文章API的URL处理成数组,并循环抓取所有的文章数据。
获取专栏的信息,专栏信息中有专栏文章的数量值,需要先获取到这个信息才能知道循环请求何时停止。使用lodash的模板方法,得到url,因为返回的数据是压缩过的,需要request解压,所以要设置gzip: true
。
const zhuanlanInfo = async (columnsID) => {
const urlTemplate = _.template(API.post.columns)({ columnsID });
let object = {};
object = {
url: urlTemplate,
gzip: true,
};
return JSON.parse((await request(object)).body);
}
将文章数量转换为循环次数。每次访问20条数据,减少请求的数量,节省时间的同时,还能减少对知乎服务器的负担,没毛病。(这里没写延时函数,如果持续抓取,建议加个延时函数)。
let rateMethod = (count, cycle) => {
count = count === undefined ? 20 : count;
cycle = cycleMethod(cycle);
let posts = count % cycle;
let times = (count - posts) / cycle;
return {
times,
count,
cycle,
writeTimes: 0,
allObject: {}
}
}
let cycleMethod = (cycle) => {
let defaultCycle = 20;
if (cycle && cycle !== defaultCycle) {
cycle = cycle % defaultCycle;
}
cycle = cycle || defaultCycle;
return cycle;
}
这里得到的循环次数是从0开始计算的。
循环函数获取所有文章
let loopMethod = (config, callback) => {
let { urlTemplate, ...options } = config.options;
let opts = {
url: url.resolve(urlTemplate, `?limit=${config.cycle}&offset=${config.writeTimes * 20}`),
...options
}
request(opts).then((c) => {
c = JSON.parse(c.body);
forEach(c, (item, index) => {
config.allObject[index + config.writeTimes * 20] = item;
});
if (config.writeTimes === config.times) {
callback(config.allObject);
} else {
config.writeTimes += 1;
loopMethod(config, callback);
}
});
}
这里使用的是callback,当然也可以写成Promise,谁写成Promise记得给我PR一下哈。
抓取过程到这里就完了。下面是数据处理的工作了。
MarkDown 数据处理与储存
获取到数据,最后需要保存成MarkDown的格式。这个模块处理的事情就是将所有的数据转换成MarkDown语法格式,保存到本地。
这一块的代码还是存在一些bug,在windows系统下,文件命名不能存在特殊字符,有心的朋友可以给我PR修复下问题哦。具体怎么写的可以看看我的代码仓库,这里就不多说了。
食用方法emmmmmm......自己看README吧。