使用 nodejs 寫爬蟲(二): 抓取 github 熱門專案
其實爬蟲是一個對計算機綜合能力要求比較高的技術活。
首先是要對網路協議尤其是 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 的倉庫。分析下面的截圖,注意圖片中的註釋:

分析一下可以得出以下資訊:
Doc
然後我又想 github 會不會檢查 cookies 和其它請求頭比如 referer,host 等,根據是否有這些請求頭決定是否返回頁面。

比較簡單的測試方法是直接用命令列工具 curl
來測試, 在 gitbash 中輸入下面命令即 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 :blush:。後續的教程可能就是一個更復雜的案例,通過分析 ajax 請求來直接訪問介面。