其实爬虫是一个对计算机综合能力要求比较高的技术活。
首先是要对网络协议尤其是 http 协议有基本的了解,能够分析网站的数据请求响应。学会使用一些工具,简单的情况使用 chrome devtools 的 network 面板就够了。我一般还会配合 postman 或者 charles 来分析,更复杂的情况可能举要使用专业的抓包工具比如 wireshark 了。你对一个网站了解的越深,越容易想出简单的方式来爬取你想获取的信息。
除了要了解一些计算机网络的知识,你还需要具备一定的字符串处理能力,具体来说就是正则表达式玩的溜,其实正则表达式一般的使用场景下用不到很多高级知识,比较常用的有点小复杂的就是分组,非贪婪匹配等。俗话说,学好正则表达式,处理字符串都不怕 🤣。
还有就是掌握一些反爬虫技巧,写爬虫你可能会碰到各种各样的问题,但是不要怕,再复杂的 12306 都有人能够爬,还有什么是能难到我们的。常见的爬虫碰到的问题比如服务器会检查 cookies, 检查 host 和 referer 头,表单中有隐藏字段,验证码,访问频率限制,需要代理,spa 网站等等。其实啊,绝大多数爬虫碰到的问题最终都可以通过操纵浏览器爬取的。
这篇使用 nodejs 写爬虫系列第二篇。实战一个小爬虫,抓取 github 热门项目。想要达到目标:
- 学会从网页源代码中提取数据这种最基本的爬虫
- 使用 json 文件保存抓取的数据
- 熟悉我上一篇介绍的一些模块
- 学会 node 中怎样处理用户输入
分析需求
我们的需求是从 github 上抓取热门项目数据,也就是 star 数排名靠前的项目。但是 github 好像没有哪个页面可以看到排名靠前的项目。往往网站提供的搜索功能是我们写爬虫的人分析的重点对象。
我之前在 v2ex 灌水的时候,看到一个讨论 996 的帖子上刚好教了一个查看 github stars 数前几的仓库的方法。其实很简单,就是在 github 搜索时加上 star 数的过滤条件比如:stars:>60000,就可以搜索到 github 上所有 star 数大于 60000 的仓库。分析下面的截图,注意图片中的注释:

分析一下可以得出以下信息:
- 这个搜索结果页面是通过 get 请求返回 html 文档的,因为我 network 选择了 Doc过滤
- url 中的请求的参数有 3 个,p(page) 代表页面数,q(query) 代表搜索内容,type 代表搜索内容的类型
然后我又想 github 会不会检查 cookies 和其它请求头比如 referer,host 等,根据是否有这些请求头决定是否返回页面。

比较简单的测试方法是直接用命令行工具 curl 来测试,在 git bash 中输入下面命令即 curl "请求的url"
curl "https://github.com/search?p=2&q=stars%3A%3E60000&type=Repositories"不出意外的正常的返回了页面的源代码,这样的话我们的爬虫脚本就不用加上请求头和 cookies 了。

通过 chrome 的搜索功能,我们可以看到网页源代码中就有我们需要的项目信息

分析到此结束,这其实就是一个很简单的小爬虫,我们只需要配置好查询参数,通过 http 请求获取到网页源代码,然后利用解析库解析,获取源代码中我们需要的和项目相关的信息,再处理一下数据成数组,最后序列化成 json 字符串存储到到 json 文件中。

动手来实现这个小爬虫
获取源代码
想要通过 node 获取源代码,我们需要先配置好 url 参数,再通过 superagent 这个发送 http 请求的模块来访问配置好的 url。
'use strict';
const requests = require('superagent');
const cheerio = require('cheerio');
const constants = require('../config/constants');
const logger = require('../config/log4jsConfig').log4js.getLogger('githubHotProjects');
const requestUtil = require('./utils/request');
const models = require('./models');
/**
 * 获取 star 数不低于 starCount k 的项目第 page 页的源代码
 *
 * @param {number} starCount Star 数量下限
 * @param {number} page 页数
 */
const crawlSourceCode = async (starCount, page = 1) => {
  // 下限为 starCount k star 数
  starCount = starCount * 1024;
  // 替换 url 中的参数
  const url = constants.searchUrl.replace('${starCount}', starCount).replace('${page}', page);
  // response.text 即为返回的源代码
  const { text: sourceCode } = await requestUtil.logRequest(requests.get(encodeURI(url)));
  return sourceCode;
};上面代码中的 constants 模块是用来保存项目中的一些常量配置的,到时候需要改常量直接改这个配置文件就行了,而且配置信息更集中,便于查看。
module.exports = {
  searchUrl: 'https://github.com/search?q=stars:>${starCount}&p=${page}&type=Repositories',
};解析源代码获取项目信息
这里我把项目信息抽象成了一个 Repository 类了。在项目的 models 目录下的 Repository.js 中。
const fs = require('fs-extra');
const path = require('path');
module.exports = class Repository {
  static async saveToLocal(repositories, indent = 2) {
    await fs.writeJSON(path.resolve(__dirname, '../../out/repositories.json'), repositories, {
      spaces: indent,
    });
  }
  constructor({ name, author, language, digest, starCount, lastUpdate } = {}) {
    this.name = name;
    this.author = author;
    this.language = language;
    this.digest = digest;
    this.starCount = starCount;
    this.lastUpdate = lastUpdate;
  }
  display() {
    console.log(`   项目:${this.name} 作者:${this.author} 语言:${this.language} star: ${this.starCount}
摘要:${this.digest}
最后更新:${this.lastUpdate}
`);
  }
};解析获取到的源代码我们需要使用 cheerio 这个解析库,使用方式和 jquery 很相似。
/**
 * 获取 star 数不低于 starCount k 的项目页表
 *
 * @param {number} starCount Star 数量下限
 * @param {number} page 页数
 */
const crawlProjectsByPage = async (starCount, page = 1) => {
  const sourceCode = await crawlSourceCode(starCount, page);
  const $ = cheerio.load(sourceCode);
  // 下面 cheerio 如果 jquery 比较熟应该没有障碍,不熟的话 github 官方仓库可以查看 api, api 并不是很多
  // 查看 elements 面板,发现每个仓库的信息在一个 li 标签内,下面的代码时建议打开开发者工具的 elements 面板,参照着阅读
  const repositoryLiSelector = '.repo-list-item';
  const repositoryLis = $(repositoryLiSelector);
  const repositories = [];
  repositoryLis.each((index, li) => {
    const $li = $(li);
    // 获取带有仓库作者和仓库名的 a 链接
    const nameLink = $li.find('h3 a');
    // 提取出仓库名和作者名
    const [author, name] = nameLink.text().split('/');
    // 获取项目摘要
    const digestP = $($li.find('p')[0]);
    const digest = digestP.text().trim();
    // 获取语言
    // 先获取类名为 .repo-language-color 的那个 span, 在获取包含语言文字的父 div
    // 这里要注意有些仓库是没有语言的,是获取不到那个 span 的,language 为空字符串
    const languageDiv = $li.find('.repo-language-color').parent();
    // 这里注意使用 String.trim() 去除两侧的空白符
    const language = languageDiv.text().trim();
    // 获取 star 数量
    const starCountLinkSelector = '.muted-link';
    const links = $li.find(starCountLinkSelector);
    // 选择器为 .muted-link 还有可能是那个 issues 链接
    const starCountLink = $(links.length === 2 ? links[1] : links[0]);
    const starCount = starCountLink.text().trim();
    // 获取最后更新时间
    const lastUpdateElementSelector = 'relative-time';
    const lastUpdate = $li.find(lastUpdateElementSelector).text().trim();
    const repository = new models.Repository({
      name,
      author,
      language,
      digest,
      starCount,
      lastUpdate,
    });
    repositories.push(repository);
  });
  return repositories;
};有时候搜索结果是有很多页的,所以我这里又写了一个新的函数用来获取指定页面数量的仓库。
const crawlProjectsByPagesCount = async (starCount, pagesCount) => {
  if (pagesCount === undefined) {
    pagesCount = await getPagesCount(starCount);
    logger.warn(`未指定抓取的页面数量,将抓取所有仓库,总共${pagesCount}页`);
  }
  const allRepositories = [];
  const tasks = Array.from({ length: pagesCount }, (ele, index) => {
    // 因为页数是从 1 开始的,所以这里要 i + 1
    return crawlProjectsByPage(starCount, index + 1);
  });
  // 使用 Promise.all 来并发操作
  const resultRepositoriesArray = await Promise.all(tasks);
  resultRepositoriesArray.forEach((repositories) => allRepositories.push(...repositories));
  return allRepositories;
};让爬虫项目更人性化
只是写个脚本,在代码里面配置参数然后去爬,这有点太简陋了。这里我使用了一个可以同步获取用户输入的库readline-sync,加了一点用户交互,后续的爬虫教程我可能会考虑使用 electron 来做个简单的界面,下面是程序的启动代码。
const readlineSync = require('readline-sync');
const { crawlProjectsByPage, crawlProjectsByPagesCount } = require('./crawlHotProjects');
const models = require('./models');
const logger = require('../config/log4jsConfig').log4js.getLogger('githubHotProjects');
const main = async () => {
  let isContinue = true;
  do {
    const starCount = readlineSync.questionInt(
      `输入你想要抓取的 github 上项目的 star 数量下限,单位 (k): `,
      {
        encoding: 'utf-8',
      },
    );
    const crawlModes = ['抓取某一页', '抓取一定数量页数', '抓取所有页'];
    const index = readlineSync.keyInSelect(crawlModes, '请选择一种抓取模式');
    let repositories = [];
    switch (index) {
      case 0: {
        const page = readlineSync.questionInt('请输入你要抓取的具体页数:');
        repositories = await crawlProjectsByPage(starCount, page);
        break;
      }
      case 1: {
        const pagesCount = readlineSync.questionInt('请输入你要抓取的页面数量:');
        repositories = await crawlProjectsByPagesCount(starCount, pagesCount);
        break;
      }
      case 3: {
        repositories = await crawlProjectsByPagesCount(starCount);
        break;
      }
    }
    repositories.forEach((repository) => repository.display());
    const isSave = readlineSync.keyInYN('请问是否要保存到本地 (json 格式) ?');
    isSave && models.Repository.saveToLocal(repositories);
    isContinue = readlineSync.keyInYN('继续还是退出 ?');
  } while (isContinue);
  logger.info('程序正常退出...');
};
main();来看看最后的效果
这里要提一下 readline-sync 的一个 bug,,在 windows 上,vscode 中使用 git bash 时,中文会乱码,无论你文件格式是不是 utf-8。搜了一些 issues,在 powershell 中切换编码为 utf-8 就可以正常显示,也就是把页码切到 65001。


项目的完整源代码以及后续的教程源代码都会保存在我的 github 仓库:Spiders。如果我的教程对您有帮助,希望不要吝啬您的 star 😊。后续的教程可能就是一个更复杂的案例,通过分析 ajax 请求来直接访问接口。