vlambda博客
学习文章列表

前端爬虫框架node-crawler牛刀小试

网络爬虫(又称为网页蜘蛛,网络机器人,在FOAF社区中间,更经常的称为网页追逐者),是一种按照一定的规则,自动地抓取万维网信息的程序或者脚本。另外一些不常使用的名字还有蚂蚁、自动索引、模拟程序或者蠕虫。

目前用于爬虫最多的语言应该就是python了,但不代表其他语言不适合,对于初学者来说,使用自己擅长的语言进行练习无疑能快速入门。这里有各个语言较为流行的爬虫框架汇总,选择你喜欢的一款,立马开始吧。

爬虫的原理可以简单的归结为:

  1. 发起网络请求

  2. 解析响应数据

  3. 将数据提取、转换为需要的信息

大部分响应数据都来自网页,及html代码,因此,解析html的效率和方便程度是爬虫框架是否优秀的参考之一。

下面我们选择node.js开发的 node-crawler 框架进行简单的爬虫实战。

准备工作

创建crawler-demo文件夹,安装crawler库:

1mkdir crawler-demo
2npm init -y
3npm install crawler

示例篇

起步

 1const Crawler = require('crawler');
2
3// 1.创建Crawler实例
4const c = new Crawler({
5    // 连接池最大数量(默认值10)
6    maxConnections: 10,
7    // 总回调:每一次请求执行的默认响应
8    callback: (err, res, done) => {
9        if(err) {
10            console.error(err.name, ':', err.message || err.stack);
11        } else {
12            const $ = res.$; // 集成的Cheerio模块,用于查询html
13            console.log($('title').text());
14        }
15        done();
16    },
17});
18
19// 2.定义查询任务
20
21// 请求一个网址
22c.queue('https://www.zhihu.com/hot');
23// 请求多个网址
24c.queue(['https://www.typescriptlang.org/''https://juejin.cn/']);
25// 带参数请求一个网址(也支持多个)
26c.queue({
27    uri'http://node-crawler.org/',
28    jqueryfalse,
29    // 如果请求队列已经自带了回调,总回调将不会再触发
30    callback: (err, res, done) => {
31        if (err) {
32            console.error(err.name, ':', err.message || err.stack);
33        } else {
34            console.log('Grabbed', res.body.length, 'bytes');
35        }
36        done();
37    },
38});
39// 测试html。一般只用于本地测试,相当于跳过服务器请求,直接拿到了html源码,后续执行逻辑一致
40c.queue({
41    html'<title>This is a test page!</title>'
42});

使用十分简单:

  • 创建Crawler实例

  • 使用queue定义查询队列

查询结果:

1This is a test page!
2掘金 - 代码不止,掘金不停
3Grabbed 58147 bytes
4知乎 - 有问题,就会有答案
5TypeScript: JavaScript With Syntax For Types.

同步请求队列

请求队列默认是异步执行的,如果你需要请求队列同步执行,可以使用rateLimit参数,以设置请求频率:每个请求之间的间隔时间(毫秒)。

1new Crawler({
2    // 设置请求频率为1000ms(每个请求之间间隔1000ms之后再继续下一个请求)
3    rateLimit: 1000,
4    callback(err, res, done) =>{},
5});

如果设置了rateLimitmaxConnections实际上也就变成1了,每次只会同时发起一个请求。

自定义参数(Custom parameters)

有时候我们需要在回调函数中取得一些特殊请求任务的参数,我们只需要将参数传给queue函数,这些自定义参数将会自动添加到回调函数的响应数据res.options中。

 1const Crawler = require('crawler');
2
3const c = new Crawler({
4    callback(err, res, done) => {
5        if(err) {
6            console.error(err);
7        } else {
8            console.log(res.options);
9        }
10        done();
11    },
12});
13
14c.queue([{
15    jqueryfalse,
16    html'<title>This is a test page!</title>',
17    name'jinx',
18    age22,
19}, {
20    jqueryfalse,
21    html'<title>This is a test page2!</title>',
22    name'yasuo',
23    age24,
24}]);

原始数据(Raw Body)

大多数时候我们都是访问网页源码,但有时候我们需要直接获取一些数据资源,比如图片、压缩包等,这时请求数据就不需要转义,即原始数据,我们可以设置编码类型encodenull来达到这个效果:

 1const Crawler = require('crawler');
2const fs = require('fs');
3
4const c = new Crawler({
5    callback(err, res, done) => {
6        if(err) {
7            console.error(err);
8        } else {
9            const filename = res.options.filename;
10            if(filename) {
11                console.log('download file:', filename)
12                fs.createWriteStream(filename).write(res.body);
13            }
14        }
15        done();
16    },
17});
18
19c.queue({
20    jqueryfalse,
21    encodingnull,
22    uri'https://raw.githubusercontent.com/bda-research/node-crawler/master/crawler_primary.png',
23    filename'crawler_primary.png',
24});

前置请求(preRequest)

如果你想在每个请求之前同步或异步地执行某些前置逻辑,可以使用preRequest字段。

 1const Crawler = require('crawler');
2
3const c = new Crawler({
4    preRequest(options, done) => {
5        console.log('pre =>', options.name);
6        options.name = options.name.toUpperCase();
7        done();
8    },
9    callback(err, res, done) => {
10        console.log('call =>', res.options.name);
11        done();
12    }
13});
14
15c.queue({
16    uri'https://www.zhihu.com/hot',
17    name'jinx',
18    age22,
19});

注意:使用本地html发起的测试不会执行preRequest响应。此外,修改preRequest中的options参数,并不会影响callback中的res参数。

发起直接请求(send request directly)

如果你不希望某个请求经过Crawler实例的调度中心,可以将queue函数替换为direct函数:

 1crawler.direct({
2    uri'http://www.google.com',
3    skipEventRequestfalse// default to true, direct requests won't trigger Event:'request'
4    callback: (error, response) => {
5        if (error) {
6            console.log(error)
7        } else {
8            console.log(response.statusCode);
9        }
10    }
11});

启用http2

node-crawler现在只支持HTTP请求。HTTP2请求的代理功能现在不包括在内,未来可能会内置进来。我们可以设置http2true以发起http2请求。

1crawler.queue({
2    //unit test work with httpbin http2 server. It could be used for test
3    uri: 'https://nghttp2.org/httpbin/status/200',
4    method'GET',
5    http2true//set http2 to be true will make a http2 request
6    callback: (error, response, done) => done(),
7});

更多使用细节请参考官方文档:http://node-crawler.org/。

实战篇

最近在看缠中说禅的《教你炒股票》系列博文,由于是发布在新浪博客的博文,分布散乱,不方便阅读,且有部分文章已经关闭无法阅览,虽然网上也有很多整理版,有配图有注解,但我还是比较倾向于看原文,所以才有了这篇爬虫笔记。

需求分析

明确需求:从缠中说缠的博客主页爬取所有《教你炒股票》系列的文章,整理为markdown文档。

请求路径

我们先来手动模拟一下爬取的过程:

  1. 访问博客首页:缠中说缠

    可以看到,博客由左右两个区域组成,左侧是分类区,右侧是博文区,可翻页预览所有博文。如下图所示:

    前端爬虫框架node-crawler牛刀小试

    我们要爬取到《教你炒股票》系列文章,就有了两种访问路径:

  • 从分类目录里爬取文章

  • 从博文预览区爬取文章

    由于《教你炒股票》文章分类比较明确,不是散乱的,因此我们采用从分类目录爬取的方案。

  1. 访问时政经济(缠中说禅经济学)目录

    前端爬虫框架node-crawler牛刀小试

    可以看到有近500篇文章,接下来,我们还要从这500篇文章中找到《教你炒股票》系列的108位好汉。

  2. 提取系列文章链接

    我们发现,该系列均以"教你炒股票"作为文章标题前缀,因此十分方便我们定位:

    前端爬虫框架node-crawler牛刀小试
  3. 文章目录翻页

    文章目录默认只会显示一页的内容,我们需要翻页以便遍历所有文章,我们可以通过页面上的下一页按钮来获得下一页的文章:

    前端爬虫框架node-crawler牛刀小试

到这一步,我们所有的请求路径都已经很明确了,下面就是提取信息,转为markdown文件的逻辑编写了。

转换格式

当我们无意外地拿到页面数据后,就可以转换为方便阅览的markdown文件了,以教你炒股票108:何谓底部?从月线看中期走势演化第一段为例,转换的markdow文档格式如下:

1## 何谓底部?从月线看中期走势演化
2
3> 原文地址:[教你炒股票108:何谓底部?从月线看中期走势演化](http://blog.sina.com.cn/s/blog_486e105c0100abkx.html)
4>
5> 时间:`2008-08-29 09:15:01`

6
7何谓底部?这里给出精确的定义,以后就不会糊涂一片了。底部都是分级别的,如果站在精确走势类型的角度,那么第一类买点出现后一知道该买点所引发的中枢第一次走出第三类买卖点前,都可以看成底部构造的过程。只不过如果是第三类卖点先出现,就意味着这底部构造失败了,反之,第三类买点意味着底部构造的最终完成并展开新的行情。当然,顶部的情况,反过来定义就是。

预览效果:

前端爬虫框架node-crawler牛刀小试

准备工作

理顺了需求,就可以开始着手代码编写了。为了更好地管理文件和资源,我们最好一开始就按照新项目的一般规范来编写代码和整理资源文件。

我设想了一下,当markdown文件转换完毕,我还是比较懒,不想一个文件一个文件地去点击查看,因此我希望能自动再将markdown文件转义为html代码,以便在浏览器上预览文章。感觉绕了一个圈是吧,一开始我们从博客中提取html源码,转为markdown文件,现在又转回html代码在浏览器上预览,那为何一开始就直接在浏览器中阅览?此言差矣,博客页面不止我们需要的文章,还有各种其他文章,甚至广告之类的无关页面,而通过爬虫,我们提取的完全是我们所需的信息,至于再转为html来预览完全是个人选择而已,这个时候,你可以转为你喜欢的任意预览形式。

现在我们需要创建一个web项目作为工作目录,为了快速启动项目,我拉取了webapp-quick-start仓库作为项目的启动脚手架。

拉取web脚手架仓库代码

webapp-quick-start是笔者自己写的一个小仓库,用于快速启用前端应用,切换分支可以选择你喜欢的web构建工具。

1git clone [email protected]:fongzhizhi/webapp-quick-start.git jiao-ni-chao-gu-piao

切换到gulp分支

1cd jiao-ni-chao-gu-piao
2git checkout gulp

项目启动测试

1npm install
2npm run dev

不出意外的话,浏览器会自动打开本地启用的服务:

前端爬虫框架node-crawler牛刀小试

提取文章请求路径

 1/**
2 * 收集请求路径
3 * @returns {Promise<{title: string;url: string;}[]>}
4 */

5function collectUrl({
6  return new Promise((res) => {
7    const urlMap = [];
8    const queryEndEventName = "query-end";
9    const c = new Crawler({
10      callback(err, res, done) => {
11        if (err) {
12          console.error(err);
13        } else {
14          const $ = res.$;
15          // 查询文章
16          const articles = $(".articleList .atc_title > a");
17          articles.map((i, el) => {
18            const title = el.firstChild && el.firstChild.data;
19            const url = el.attribs && el.attribs.href;
20            if (url && title && title.trim().startsWith("教你炒股票")) {
21              urlMap.push({
22                title,
23                url,
24              });
25            }
26          });
27          // 判断是否有下一页
28          const nextPageUrl = $(".SG_pgnext > a").attr("href");
29          if (nextPageUrl) {
30            // 继续查询下一页
31            c.queue(nextPageUrl);
32          } else {
33            // 结束查询
34            c.emit(queryEndEventName);
35          }
36        }
37        done();
38      },
39    });
40
41    // 目录首页
42    c.queue("http://blog.sina.com.cn/s/articlelist_1215172700_10_1.html");
43
44    // 监听查询结束事件
45    c.addListener(queryEndEventName, () => {
46      res(urlMap);
47    });
48  });
49}

提取文章源码

 1/**
2 * 收集文章
3 */

4async function collectArticle(downloadPath{
5  downloadPath = downloadPath || defaultDownloadPath;
6  const map = await collectUrl();
7  const articleMap = {}; // 文章信息
8  let total = map.length;
9
10  const c = new Crawler({
11    callback(err, res, done) => {
12      total--;
13      if (err) {
14        console.error(err);
15      } else {
16        const $ = res.$;
17        const contentBody = $(".SG_connBody");
18        // 收集信息
19        const title = contentBody.find(".articalTitle .titName").text();
20        const sourceUrl = res.options.uri;
21        // 文章title信息
22        title.match(/(\d+)/);
23        articleMap[RegExp.$1 || title] = {
24          title,
25          url: sourceUrl,
26        };
27
28        let timer = contentBody.find(".articalTitle .time").text();
29        if (timer.match(/\((.+)\)/)) {
30          timer = RegExp.$1;
31        }
32        const content = getParagraphs(contentBody);
33        // 转换为markdown文档
34        createMarkDownFile({
35          title,
36          sourceUrl,
37          content,
38          timer,
39          downloadPath,
40        });
41      }
42      if (total <= 0) {
43        !fs.existsSync(downloadPath) && fs.mkdirSync(downloadPath);
44        const filePth = path.resolve(downloadPath, "articleMap.json");
45        fs.writeFileSync(filePth, JSON.stringify(articleMap, null2));
46      }
47      done();
48    },
49  });
50  map.forEach((item) => {
51    c.queue(item.url);
52  });
53}
54
55/**
56 * @param contentBody {cheerio.Cheerio}
57 */

58function getParagraphs(contentBody{
59  const fonts = contentBody.find(
60    ".articalContent > div > p, .articalContent > div > font"
61  );
62  let res = "";
63  fonts.map((i, item) => {
64    const paragraph = getChildContent(item, "");
65    paragraph && (res += os.EOL + paragraph + os.EOL);
66  });
67
68  return res;
69
70  function getChildContent(item, paragraph{
71    item.children &&
72      item.children.forEach((e) => {
73        if (e.name === "img") {
74          e.attribs.real_src &&
75            (paragraph +=
76              os.EOL +
77              `![${e.attribs.alt || e.attribs.title}](${e.attribs.real_src})` +
78              os.EOL);
79        } else {
80          const text = e.data && e.data.trim();
81          text && (paragraph += text);
82          paragraph = getChildContent(e, paragraph);
83        }
84      });
85    return paragraph;
86  }
87}

生成markdown文件

 1/**
2 * 生成markdown文档
3 * @param {{title: string; sourceUrl: string; content: string; timer: string; downloadPath?: string;}} opts
4 */

5function createMarkDownFile(opts{
6  const newLine = os.EOL;
7  // title
8  let mdStr = "## " + opts.title + newLine;
9  // introduction
10  mdStr += newLine;
11  mdStr += "> ";
12  mdStr += `原文地址:[${opts.title}](${opts.sourceUrl})`;
13  mdStr += newLine;
14  mdStr += "> " + newLine + "> ";
15  mdStr += `时间:\`${opts.timer}\`` + newLine;
16  // content
17  mdStr += newLine + opts.content;
18  // download
19  if (opts.downloadPath) {
20    !fs.existsSync(opts.downloadPath) && fs.mkdirSync(opts.downloadPath);
21    const filePth = path.resolve(opts.downloadPath, opts.title + ".md");
22    fs.writeFileSync(filePth, mdStr);
23    console.log("[WritingFile", filePth);
24  }
25  return mdStr;
26}

执行脚本

以上代码位于.src/crawler/crawlerArticles.js文件,我们使用node执行即可看到效果:

文章预览

为更方便地预览文章,参考《缠论土匪版pdf》编写了一份文章目录索引,所有代码均已上传至https://github.com/fongzhizhi/jiao-ni-chao-gu-piao仓库,欢迎自取。

  • 爬取文章

    1npm run crawl
  • 文章预览

    1npm run dev

其他问题