使用 nodejs 寫爬蟲(二): 抓取 github 熱門專案

lyreal666發表於2019-04-05

其實爬蟲是一個對計算機綜合能力要求比較高的技術活。

首先是要對網路協議尤其是 http 協議有基本的瞭解, 能夠分析網站的資料請求響應。學會使用一些工具,簡單的情況使用 chrome devtools 的 network 皮膚就夠了。我一般還會配合 postman 或者 charles 來分析,更復雜的情況可能舉要使用專業的抓包工具比如 wireshark 了。你對一個網站了解的越深,越容易想出簡單的方式來爬取你想獲取的資訊。

除了要了解一些計算機網路的知識,你還需要具備一定的字串處理能力,具體來說就是正規表示式玩的溜,其實正規表示式一般的使用場景下用不到很多高階知識,比較常用的有點小複雜的就是分組,非貪婪匹配等。俗話說,學好正規表示式,處理字串都不怕?。

還有就是掌握一些反爬蟲技巧,寫爬蟲你可能會碰到各種各樣的問題,但是不要怕,再複雜的 12306 都有人能夠爬,還有什麼是能難到我們的。常見的爬蟲碰到的問題比如伺服器會檢查 cookies, 檢查 host 和 referer 頭,表單中有隱藏欄位,驗證碼,訪問頻率限制,需要代理, spa 網站等等。其實啊,絕大多數爬蟲碰到的問題最終都可以通過操縱瀏覽器爬取的。

這篇使用 nodejs 寫爬蟲系列第二篇。實戰一個小爬蟲,抓取 github 熱門專案。想要達到目標:

  1. 學會從網頁原始碼中提取資料這種最基本的爬蟲
  2. 使用 json 檔案儲存抓取的資料
  3. 熟悉我上一篇介紹的一些模組
  4. 學會 node 中怎樣處理使用者輸入

分析需求

我們的需求是從 github 上抓取熱門專案資料,也就是 star 數排名靠前的專案。但是 github 好像沒有哪個頁面可以看到排名靠前的專案。往往網站提供的搜尋功能是我們寫爬蟲的人分析的重點物件

我之前在 v2ex 灌水的時候,看到一個討論 996 的帖子上剛好教了一個檢視 github stars 數前幾的倉庫的方法。其實很簡單,就是在 github 搜尋時加上 star 數的過濾條件比如: stars:>60000,就可以搜尋到 github 上所有 star 數大於 60000 的倉庫。分析下面的截圖,注意圖片中的註釋:

github-hot-projects

分析一下可以得出以下資訊:

  1. 這個搜尋結果頁面是通過 get 請求返回 html 文件的,因為我 network 選擇了 Doc 過濾
  2. url 中的請求的引數有3個,p(page) 代表頁面數,q(query) 代表搜尋內容,type 代表搜尋內容的型別

然後我又想 github 會不會檢查 cookies 和其它請求頭比如 referer,host 等,根據是否有這些請求頭決定是否返回頁面。

request headers

比較簡單的測試方法是直接用命令列工具 curl 來測試, 在 gitbash 中輸入下面命令即 curl "請求的url"

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。

'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

example

repositories-json

專案的完整原始碼以及後續的教程原始碼都會儲存在我的 github 倉庫: Spiders。如果我的教程對您有幫助,希望不要吝嗇您的 star ?。後續的教程可能就是一個更復雜的案例,通過分析 ajax 請求來直接訪問介面。

相關文章