使用 nodejs 寫爬蟲(二): 抓取 github 熱門項目

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

首先是要對網絡協議尤爲是 http 協議有基本的瞭解, 可以分析網站的數據請求響應。學會使用一些工具,簡單的狀況使用 chrome devtools 的 network 面板就夠了。我通常還會配合 postman 或者 charles 來分析,更復雜的狀況可能舉要使用專業的抓包工具好比 wireshark 了。你對一個網站了解的越深,越容易想出簡單的方式來爬取你想獲取的信息。html

除了要了解一些計算機網絡的知識,你還須要具有必定的字符串處理能力,具體來講就是正則表達式玩的溜,其實正則表達式通常的使用場景下用不到不少高級知識,比較經常使用的有點小複雜的就是分組,非貪婪匹配等。俗話說,學好正則表達式,處理字符串都不怕🤣。java

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

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

  1. 學會從網頁源代碼中提取數據這種最基本的爬蟲
  2. 使用 json 文件保存抓取的數據
  3. 熟悉我上一篇介紹的一些模塊
  4. 學會 node 中怎樣處理用戶輸入

分析需求

咱們的需求是從 github 上抓取熱門項目數據,也就是 star 數排名靠前的項目。可是 github 好像沒有哪一個頁面能夠看到排名靠前的項目。每每網站提供的搜索功能是咱們寫爬蟲的人分析的重點對象git

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

github-hot-projects

分析一下能夠得出如下信息:ajax

  1. 這個搜索結果頁面是經過 get 請求返回 html 文檔的,由於我 network 選擇了 Doc 過濾
  2. url 中的請求的參數有3個,p(page) 表明頁面數,q(query) 表明搜索內容,type 表明搜索內容的類型

而後我又想 github 會不會檢查 cookies 和其它請求頭好比 referer,host 等,根據是否有這些請求頭決定是否返回頁面。正則表達式

request headers

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

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 請求來直接訪問接口。

相關文章
相關標籤/搜索