专栏首页全栈修仙之路Node.js 小打小闹之爬虫入门

Node.js 小打小闹之爬虫入门

网络爬虫(英语:web crawler),也叫网络蜘蛛(spider),是一种用来自动浏览万维网的网络机器人。此外爬虫还可以验证超链接和 HTML 代码,用于网络抓取。

本文我们将以爬取我的个人博客前端修仙之路已发布的博文为例,来实现一个简单的 Node.js 爬虫。在实际动手前,我们来看分析一下,人为统计的流程:

  1. 新建一个 Excel 表或文本文件;
  2. 打开浏览器,访问前端修仙之路;
  3. 浏览当前页,复制所需的信息,如文章标题、发布时间、文章分类及字数统计等;
  4. 若存在下一页,则访问下一页,然后执行上面的第 3 步操作;
  5. 数据收集完成,进行数据保存操作。

了解完上述的流程,我们来分析一下使用 Node.js 应该如何实现上述的功能。我的博客是基于 Hexo 搭建,Hexo 是一个快速、简洁且高效的博客框架。Hexo 使用 Markdown(或其他渲染引擎)解析文章,在几秒内,即可利用靓丽的主题生成静态网页

由于博客上使用的是静态网页,因此我们只要能获取网页的 HTML 内容就跨出了一大步,在获取页面内容后,我们就能对网页进行解析,进而提取并保存所需的信息,之后如果发现还有下一页的话,我们就重复上述的流程。

现在我们可以把爬取的任务分为 3 个主要的流程:

  1. 获取网页的 HTML 内容;
  2. 解析 HTML 内容,抽取相应的文章信息;
  3. 保存已获取的内容。

此时,我们的流程已梳理清楚,让我们开启爬虫之旅。

获取网页的 HTML 内容

想要获取网页的内容,我们可以利用 HTTP 客户端来发送 HTTP 请求,这里我们选用 request 这个库。这个库使用起来非常的简单:

const request = require('request');

request('http://www.google.com', function (error, response, body) {
  console.log('error:', error); // Print the error if one occurred
  console.log('statusCode:', response && response.statusCode); // Print the response status code if a response was received
  console.log('body:', body); // Print the HTML for the Google homepage.
});

它不但简单易用,而且还很强大,支持很多特性,比如:

哈哈,不错哟,也支持 Promises 和 Async/Await。这里我们将选用 Bluebird 封装的 request-promise 库。首先我们来安装一下依赖:

$ npm i request request-promise --save

安装成功后,我们就可以来小试牛刀了:

var rp = require('request-promise');

rp('http://www.semlinker.com/')
    .then(function (htmlString) {
        // Process html...
        console.log(htmlString);
    })
    .catch(function (err) {
        // Crawling failed...
    });

运行以上代码后,不出意外的话,你将会在控制台看到输出的 HTML 代码。现在我们已经完成了第一步,接下来就要进入的我们下一个环节 —— HTML 解析。

解析 HTML 内容,抽取相应的文章信息

很巧的是,在 request-promise 说明文档中遇见了这个环节的主角 —— cheerio,不信你看:

var cheerio = require('cheerio'); // Basically jQuery for node.js

var options = {
    uri: 'http://www.google.com',
    transform: function (body) {
        return cheerio.load(body);
    }
};

rp(options)
    .then(function ($) {
        // Process html like you would with jQuery...
    })
    .catch(function (err) {
        // Crawling failed or Cheerio choked...
    });

不知道你是否已经注意到这行注释 —— “Basically jQuery for node.js”,看到 jQuery 你是不是有种熟悉的感觉。言归正传,我们来会一会 cheerio

Fast, flexible, and lean implementation of core jQuery designed specifically for the server.

看完介绍是不是没有概念,我们马上来个?:

const cheerio = require('cheerio')
const $ = cheerio.load('<h2 class="title">Hello world</h2>')

$('h2.title').text('Hello there!')
$('h2').addClass('welcome')

$.html()
//=> <h2 class="title welcome">Hello there!</h2>

小伙伴们是不是感觉棒棒哒,趁热打铁,我们先来安装一下 cheerio:

$ npm install cheerio

安装成功后,马上更新一下代码:

var rp = require('request-promise');
var cheerio = require('cheerio'); // Basically jQuery for node.js

var options = {
    uri: 'http://www.semlinker.com/',
    transform: function (body) {
        return cheerio.load(body);
    }
};

rp(options)
    .then(function ($) {
        // Process html like you would with jQuery...
    })
    .catch(function (err) {
        // Crawling failed or Cheerio choked...
    });

下面的重头戏就是数据抽取了,在编码前我们先来使用 Chrome 开发者工具分析一下页面结构:

通过分析,我们发现博客标题包含在 h1 标签中,而其它的信息包含在 div 标签中。在查看 cheerio 相关 API 之后,我们可以利用以下 API 获取博文的相关信息,具体如下:

rp(options)
    .then(function ($) {
        $('.post-header').each(function (i, elem) {
            let postTitle = $(this).find('.post-title').text();
            let postTime = $(this).find('.post-time time').text();
            let postCategory = $(this).find('.post-category a>span').text();
            let postWordCount = $(this).find('.post-wordcount span[title="字数统
              计"]').text();
            console.log(postTitle, postTime, postCategory, postWordCount);
        });
})

这时候我们已经完成单个页面的解析工作,剩下的任务就是解析剩余的页面和数据保存。要想解析剩余的页面,前提就是能够获取剩余页面的链接,这里我把目光投向 “首页分页条”,它对应的 HTML 结构如下:

<nav class="pagination" style="opacity: 1; display: block;">
   <span class="page-number current">1</span>
   <a class="page-number" href="/page/2/">2</a>
   <span class="space">…</span>
   <a class="page-number" href="/page/4/">4</a>
   <a class="extend next" rel="next" href="/page/2/">
     <i class="fa fa-angle-right"></i>
   </a>
</nav>

通过上面的结构,我们可以获取当前页、下一页和总页数等信息,而且知道了页面链接的规则:/page/:page-number,所以我们已经知道如何获取所有页面的链接地址。下面我们定义一个 BlogSpider 类,用来实现上述两个流程:

import * as rp from 'request-promise';
import * as cheerio from 'cheerio';

interface SpiderOption {
  uris: any;
}

class BlogSpider {
  startUris;
  result = [];

  static create(spiderOption: SpiderOption) {
    return new BlogSpider(spiderOption);
  }

  constructor(private spiderOption: SpiderOption) {}
  
  private makeUris() {
    let { uris } = this.spiderOption;
    return typeof uris === 'string'
      ? [uris]
      : this.isIterable(uris)
        ? uris
        : [];
  }

  private isIterable(obj) {
    if (!obj) return false;
    return typeof obj[Symbol.iterator] === 'function';
  }

  async start() {
    this.startUris = this.makeUris();
    let posts;
    for (let uri of this.startUris) {
      if (!uri) return;
      posts = await this.crawl(uri);
      if (posts) this.result = this.result.concat(posts);
    }
    console.log(this.result);
    return this.result;
  }

  async crawl(uri) {
    try {
      let $ = await this.load(uri);
      return this.parse($);
    } catch (error) {
      console.error(error);
    }
  }

  load(uri: string): Promise<any> {
    let options = {
      uri,
      transform: body => cheerio.load(body),
    };
    return rp(options);
  }

  parse($) {
    let posts = [];
    $('.post-header').each(function(i, elem) {
      let postTitle = $(this)
        .find('.post-title')
        .text().trim();
      let postTime = $(this)
        .find('.post-time time')
        .text().trim();
      let postCategory = $(this)
        .find('.post-category a>span')
        .text().trim();
      let postWordCount = $(this)
        .find('.post-wordcount span[title="字数统计"]')
        .text().trim();
      posts.push({
        postTitle,
        postTime,
        postCategory,
        postWordCount,
      });
    });
    return posts;
  }
}

使用方式如下:

function* uriGenerator(baseUri, totalPage) {
  let index = 1;
  while (index <= totalPage) {
    yield index === 1 ? baseUri : `${baseUri}/page/${index}`;
    index++;
  }
}

let gen = uriGenerator('http://www.semlinker.com', 2);

// 创建BlogSpider
const spider = BlogSpider.create({
  uris: gen, // ['http://www.semlinker.com', 'http://www.semlinker.com/page/2']
});

// 启动BlogSpider
spider.start();

上面代码中,我们定义了一个 uriGenerator 生成器,用来生成爬取的 uri 地址。当然 uri 数量较少的情况下,是可以直接使用数组,使用生成器的主要目的是避免出现大数据量下的内存消耗问题。此外,也可以在初始化的时候设置一个起始地址,当爬取完当前页的时候,在获取下一页的 uri 地址,直到所有页面都爬取完成。

最后我们来介绍最后一个环节 —— 保存已获取的内容。

保存已获取的内容

在上一个环节,我们已经完成博文信息的爬取工作,在获取博文信息后,我们可以对数据进行持久化操作,比如保存到 Redis 或数据库(MongoDB、MySQL等)中,也可以把数据输出成文件。这里,我们选择的持久化方案是 —— “输出 JSON 文件”。利用 Node.js FS API,我们可以实现一个简单的 writeFile() 函数:

function writeFile(outputPath, content) {
  fs.writeFile(outputPath, content, function(err) {
    if (err) throw err;
    console.log('文件写入成功');
  });
}

创建完 writeFile() 函数,我可以在定义一个入口函数,比如:

async function main() {
  let gen = uriGenerator('http://www.semlinker.com', 2);
  const spider = BlogSpider.create({
    uris: gen,
  });
  const blogs = await spider.start();
  writeFile(__dirname + '/blog.json', JSON.stringify(blogs));
}

以上代码成功运行后,你就可以在当前目录下看到新建的 blog.json 文件,此时我们的爬虫之旅就落下帷幕。

总结

本文只是简单介绍了 Node.js 爬虫相关的知识,并未涉及多线程、分布式爬虫和一些反爬策略的应对方案,有兴趣的同学可以查阅一下相关资料。另外,在实际项目中,可以直接使用一些现成的爬虫框架,比如 node-crawler,熟悉 Python 的同学,也可以使用大名鼎鼎的 scrapy

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 深入学习 Node.js Module

    Node.js 遵循 CommonJS规范,该规范的核心思想是允许模块通过 require 方法来同步加载所要依赖的其他模块,然后通过 exports 或 mo...

    阿宝哥
  • TypeScript 函数中的 this 参数

    从 TypeScript 2.0 开始,在函数和方法中我们可以声明 this 的类型,实际使用起来也很简单,比如:

    阿宝哥
  • 了不起的 IoC 与 DI

    本文阿宝哥将从六个方面入手,全方位带你一起探索面向对象编程中 IoC(控制反转)和 DI(依赖注入) 的设计思想。阅读完本文,你将了解以下内容:

    阿宝哥
  • 少写css, 早下班! Antd完成todo-list样式布局

    zhaoolee
  • 技术分享:用Node抓站(一)

    如果只写怎么抓取网页,肯定会被吐槽太水,满足不了读者的逼格要求,所以本文会通过不断的审视代码,做到令自己满意(撸码也要不断迸发新想法!

    疯狂的技术宅
  • 微信小程序在安卓的白屏问题原因及改进讲解

    在做小程序的时候,做到了一个限时商品售卖,用到了倒计时,因为这个原因导致了安卓手机上使用小程序时,将小程序放入后台运行一段时间后,再次进入小程序后出现了页面白屏...

    砸漏
  • javascript面向对象之继承(上)

    我们之前介绍了javascript面向对象的封装的相关内容,还介绍了js的call方法,今天开始讨论js的继承 这篇文章参考了《javascript高级程序设计...

    陌上寒
  • 基于uFUN开发板的心率计(三)Qt上位机的实现

    上两周利用周末的时间,分别写了基于uFUN开发板的心率计(一)DMA方式获取传感器数据和基于uFUN开发板的心率计(二)动态阈值算法获取心率值,介绍了AD采集传...

    单片机点灯小能手
  • 重试组件使用与原理分析(一)-spring-retry

    在日常开发中,我们很多时候都需要调用二方或者三方服务和接口,外部服务对于调用者来说一般都是不可靠的,尤其是在网络环境比较差的情况下,网络抖动很容易导致请求超时等...

    叔牙
  • 带你用4行代码训练RNN生成文本(附资源)

    本文共1400字,建议阅读6分钟。 本文介绍仅需几行代码就能训练出任意大小和复杂度的文本的神经网络文本发生器。

    数据派THU

扫码关注云+社区

领取腾讯云代金券