其实爬虫是一个对计算机综合能力要求比较高的技术活。
首先是要对网络协议尤其是 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 的仓库。分析下面的截图,注意图片中的注释:
![github-hot-projects]()
分析一下可以得出以下信息:
- 这个搜索结果页面是通过 get 请求返回 html 文档的,因为我 network 选择了
Doc
过滤 - url 中的请求的参数有 3 个,p(page) 代表页面数,q(query) 代表搜索内容,type 代表搜索内容的类型
然后我又想 github 会不会检查 cookies 和其它请求头比如 referer,host 等,根据是否有这些请求头决定是否返回页面。
![request headers]()
比较简单的测试方法是直接用命令行工具 curl
来测试, 在 gitbash 中输入下面命令即 curl "请求的url"
1
| curl "https://github.com/search?p=2&q=stars%3A%3E60000&type=Repositories"
|
不出意外的正常的返回了页面的源代码, 这样的话我们的爬虫脚本就不用加上请求头和 cookies 了。
![gitbash-curl-github]()
通过 chrome 的搜索功能,我们可以看到网页源代码中就有我们需要的项目信息
![source code search]()
分析到此结束,这其实就是一个很简单的小爬虫,我们只需要配置好查询参数,通过 http 请求获取到网页源代码,然后利用解析库解析,获取源代码中我们需要的和项目相关的信息,再处理一下数据成数组,最后序列化成 json 字符串存储到到 json 文件中。
![postman-github-search]()
动手来实现这个小爬虫
获取源代码
想要通过 node 获取源代码,我们需要先配置好 url 参数, 再通过 superagent 这个发送 http 请求的模块来访问配置好的 url。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| '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');
const crawlSourceCode = async (starCount, page = 1) => { starCount = starCount * 1024; const url = constants.searchUrl .replace('${starCount}', starCount) .replace('${page}', page); const { text: sourceCode } = await requestUtil.logRequest( requests.get(encodeURI(url)) ); return sourceCode; };
|
上面代码中的 constants 模块是用来保存项目中的一些常量配置的,到时候需要改常量直接改这个配置文件就行了,而且配置信息更集中,便于查看。
1 2 3 4
| module.exports = { searchUrl: 'https://github.com/search?q=stars:>${starCount}&p=${page}&type=Repositories', };
|
解析源代码获取项目信息
这里我把项目信息抽象成了一个 Repository 类了。在项目的 models 目录下的 Repository.js 中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| 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 很相似。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
|
const crawlProjectsByPage = async (starCount, page = 1) => { const sourceCode = await crawlSourceCode(starCount, page); const $ = cheerio.load(sourceCode);
const repositoryLiSelector = '.repo-list-item'; const repositoryLis = $(repositoryLiSelector); const repositories = []; repositoryLis.each((index, li) => { const $li = $(li);
const nameLink = $li.find('h3 a');
const [author, name] = nameLink.text().split('/');
const digestP = $($li.find('p')[0]); const digest = digestP.text().trim();
const languageDiv = $li.find('.repo-language-color').parent(); const language = languageDiv.text().trim();
const starCountLinkSelector = '.muted-link'; const links = $li.find(starCountLinkSelector); 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; };
|
有时候搜索结果是有很多页的,所以我这里又写了一个新的函数用来获取指定页面数量的仓库。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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) => { return crawlProjectsByPage(starCount, index + 1); });
const resultRepositoriesArray = await Promise.all(tasks); resultRepositoriesArray.forEach(repositories => allRepositories.push(...repositories) ); return allRepositories; };
|
让爬虫项目更人性化
只是写个脚本,在代码里面配置参数然后去爬,这有点太简陋了。这里我使用了一个可以同步获取用户输入的库readline-sync,加了一点用户交互,后续的爬虫教程我可能会考虑使用 electron 来做个简单的界面, 下面是程序的启动代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| 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
。
![example]()
![repositories-json]()
项目的完整源代码以及后续的教程源代码都会保存在我的 github 仓库: Spiders。如果我的教程对您有帮助,希望不要吝啬您的 star 😊。后续的教程可能就是一个更复杂的案例,通过分析 ajax 请求来直接访问接口。