手把手教你用node擼一個簡易的handless爬蟲cli工具
handless爬蟲主要靠它。它可以模擬使用者開啟網頁的過程,但是並沒有開啟網頁。寫過自動化測試的同學應該對這個會比較熟悉,因為用它爬蟲的過程跟自動化測試的過程幾乎是一樣的。
commander
基於node的cli命令列工具。利用它,我們可以很方便的寫出各種各樣的cli命令。
inquirer
互動式命令列工具。什麼叫做互動式命令列呢?其實就是類似npm init的時候,問一個問題,我們答一個問題,最後根據答案生成package.json的過程。
chalk
這個其實就是一個讓我們在命令列中輸出的文字更加優美的工具。
好了,介紹完了工具以後,讓我們正式開始我們的專案。
專案介紹
首先,要搞清楚我們想要實現的功能。我們想要實現的功能就是,在命令列 中輸入我們想要下載的圖片,然後node去網上爬取我們想要的圖片(這裡就先去百度圖片爬吧),直接下載到本地。以及輸入一個命令,可以清空我們輸出目錄中的圖片。
檔案目錄
|-- Documents |-- .gitignore |-- README.md |-- package.json |-- bin ||-- gp |-- output ||-- .gitkeeper |-- src |-- app.js |-- clean.js |-- index.js |-- config ||-- default.js |-- helper |-- questions.js |-- regMap.js |-- srcToImg.js 複製程式碼
以上是專案用到的一個簡單的目錄結構
- output 用以存放下載的圖片
- bin cli工具會用到的檔案
-
src
程式碼主要存放於此
- index.js 專案入口檔案
- app.js 主要功能檔案
- clean.js 用於清空圖片操作的檔案
- config 用於存放一些配置
- helper 用於存放一些輔助方法的檔案
開始專案
首先我們看一下app.js。
我們用一個類包裹核心方法,是為了命令列工具可以更方便的呼叫我們的方法。
這個類很簡單,constructor
接收引數,start
開啟主要流程。start
方法是一個async函式,因為puppeteer
操作瀏覽器的過程幾乎都是非同步的。
接著我們用puppeteer
生成page的例項,利用goto
方法模擬進入百度圖片頁面。這時其實就是跟我們真實開啟瀏覽器進入百度圖片是一樣的,只不過因為我們是handless的,所以我們無法感知開啟瀏覽器的過程。
然後我們需要設定一下瀏覽器的寬度(想象一下),不能太大,也不能太小。太大會觸發百度反爬蟲機制,導致我們爬下來的圖片是403或者別的錯誤。太小會導致爬到的圖片非常少。
接下去我們聚焦搜尋框,輸入我們想要搜尋的關鍵字(這個關鍵字呢就是我們在命令列輸入的關鍵字),然後點選搜尋。
等頁面載入以後,我們用page.$$eval
獲取頁面上所有class
為.main_img
的圖片(具體規律需要自己去觀察),再獲取上面的src
屬性後,將src
轉為我們本地的圖片。
到這裡,app.js的任務就完成了。 很簡單吧。
下面是程式碼。
const puppeteer = require('puppeteer'); const chalk = require('chalk'); const config = require('./config/default'); const srcToImg = require('./helper/srcToImg'); class App { constructor(conf) { //有傳入的引數既用傳入的引數,沒有既用預設的引數 this.conf = Object.assign({}, config, conf); } async start () { //用puppeteer生成一個browser的例項 //用browser再生成一個page的例項 const browser = await puppeteer.launch(); const page = await browser.newPage(); //開啟搜尋引擎,先寫死百度 await page.goto(this.conf.searchPath); console.log(chalk.green(`go to ${this.conf.searchPath}`)); //設定視窗大小,過大會引起反爬蟲 await page.setViewport({ width: 1920, height: 700 }); //搜尋文字輸入框聚焦 await page.focus('#kw'); //輸入要搜尋的關鍵字 await page.keyboard.sendCharacter(this.conf.keyword); //點選搜尋 await page.click('.s_search'); console.log(chalk.green(`get start searching pictures`)); //頁面載入後要做的事 page.on('load', async () => { console.log(chalk.green(`searching pictures done, start fetch...`)); //獲取所有指定圖片的src const srcs = await page.$$eval('img.main_img', pictures => { return pictures.map(img => img.src); }); console.log(chalk.green(`get ${srcs.length} pictures, start download`)); srcs.forEach(async (src) => { await page.waitFor(200); await srcToImg(src, this.conf.outputPath); }); }); } }; module.exports = App; 複製程式碼
接下來我們看一下,如何把圖片的src屬性轉化為我們本地的圖片呢?我們看下helper下的srcToImg.js
首先,這個模組主要引入了node的http
模組、https
模組、path
模組和fs
模組及一些輔助工具,比如正則、將回調函式轉化為promise的promisify
和將輸出更好看的chalk
。
為什麼我們要同時引入http和https模組呢?仔細觀察百度圖片搜尋結果中的圖片,我們可以發現,既有http的也有https的,所以我們引入兩個模組,區分出具體的圖片屬於哪個就用哪個模組去請求圖片。請求了圖片以後,我們就用fs
模組的createWriteStream
方法,將圖片存入我們的output
目錄中。
如果我們仔細觀察了百度搜索結果中的圖片的src,我們會發現,除了http和https開頭的圖片,還有base64的圖片,所以我們要對base64的圖片也做一下處理。
跟普通圖片一樣的處理,先根據src
分割出副檔名,再計算出儲存的路徑和檔名,最後寫入呼叫fs
模組的writeFile
方法寫入檔案(這裡就簡單的用writeFile了)。
以上,圖片就存入本地了。
程式碼如下。
const http = require('http'); const https = require('https'); const path = require('path'); const fs = require('fs'); const { promisify } = require('util'); const chalk = require('chalk'); const writeFile = promisify(fs.writeFile); const regMap = require('./regMap'); const urlToImg = promisify((url, dir) => { let mod; if(regMap.isHttp.test(url)){ mod = http; }else if(regMap.isHttps.test(url)){ mod = https; } //獲取圖片的副檔名 const ext = path.extname(url); //拼接圖片儲存的路徑和副檔名 const file = path.join(dir, `${parseInt(Math.random() * 1000000)}${ext}`); mod.get(url, res => { //採用stream的形式,比直接寫入更快捷 res.pipe(fs.createWriteStream(file)).on('finish', () => { console.log(file); }); }); }); const base64ToImg = async (base64Str, dir) => { const matchs = base64Str.match(regMap.isBase64); try { const ext = matchs[1].split('/')[1].replace('jpeg', 'jpg'); const file = path.join(dir, `${parseInt(Math.random() * 1000000)}.${ext}`); await writeFile(file, matchs[2], 'base64'); console.log(file); } catch (error) { console.log(chalk.red('無法識別的圖片')); } }; module.exports = (src, dir) => { if(regMap.isPic.test(src)){ urlToImg(src, dir); }else{ base64ToImg(src, dir); } }; 複製程式碼
我們再看一下如何清空output下的圖片呢?這裡我們還是用到了node
的fs
模組,首先利用fs.readdir
方法讀取output
資料夾,然後遍歷其下的檔案,如果是圖片,則呼叫fs.unlink
方法刪除它。也很簡單,對吧。
程式碼如下
const fs = require('fs'); const regMap = require('./helper/regMap'); const config = require('./config/default'); const cleanPath = config.outputPath; class Clean { constructor() {} clean() { fs.readdir(cleanPath, (err, files) => { if(err){ throw err; } files.forEach(file => { if(regMap.isPic.test(file)){ const img = `${cleanPath}/${file}`; fs.unlink(img, (e) => { if(e) { throw e; } }); } }); console.log('clean finished'); }); } }; module.exports = Clean; 複製程式碼
最後我們看一下如何寫cli工具呢?首先我們需要在bin
目錄下新建一個指令碼檔案gp
,如下
#! /usr/bin/env node module.exports = require('../src/index'); 複製程式碼
意思是找到/usr/bin/env
下的node
來啟動第二行的程式碼
其次我們需要在package.json里加入一個bin
物件,物件下屬性名是我們命令的名字,屬性是bin
下的指令碼檔案的路徑,如下
"bin": { "gp": "bin/gp" } 複製程式碼
接著我們來看下index.js
const program = require('commander'); const inquirer = require('inquirer'); const pkg = require('../package.json'); const qs = require('./helper/questions'); const App = require('./app'); const Clean = require('./clean'); program .version(pkg.version, '-v, --version'); program .command('search') .alias('s') .description('get search pictures what you want.') .action(async () => { const answers = await inquirer.prompt(qs.startQuestions); const app = new App(answers); await app.start(); }); program .command('clean') .alias('c') .description('clean all pictures in directory "output".') .action(async () => { const answers = await inquirer.prompt(qs.confirmClean); const clean = new Clean(); answers.isRemove && await clean.clean(); }); program.parse(process.argv); if(process.argv.length < 3){ program.help(); } 複製程式碼
我們引入commander
和inquirer
,program.command
方法是為我們生成命令名的,alias
是該命令的縮寫,description
是該命令的描述,action
是該命令要做的事情。
我們首先用command
生成了兩個命令,search
和clean
,接著可以看到,我們在action
中用了inquirer
,inquirer
的提問是一個非同步的過程,所以我們也一樣用了async
和await
,inquirer
接收一個問題陣列,裡面包含問題的type、name、message和驗證方法等,具體的可以參考inquirer的文件。我們這裡的問題如下,這裡返回了兩個陣列,一個是用於輸入關鍵字的時候的,一個是用於清空圖片時確認的。提問陣列中會驗證是否有填寫關鍵字,如果沒有,則不會繼續下一步並提示你該輸入關鍵字,否則就正式開始爬蟲流程。刪除確認陣列就是簡單的一個確認,如果確認了,則開始刪除圖片。最後,用program.parse
將命令注入到node
的process.argv
中,根據命令列有沒有輸入引數提示help資訊。
至此,我們的程式大功告成。接下去我們只要將我們的程式釋出到npm裡,就可以讓其他人下載來使用了~npm的釋出我們這裡就不再贅述啦,不清楚的同學網上隨便搜一下就ok啦。
src/helper/questions.js
如下
const config = require('../config/default'); exports.startQuestions = [ { type: 'input', name: 'keyword', message: 'What pictures do yo want to get ?', validate: function(keyword) { const done = this.async(); if(keyword === ''){ done('Please enter the keyword to get pictures'); return; } done(null, true); } } ]; exports.confirmClean = [ { type: 'confirm', name: 'isRemove', message: `Do you want to remove all pictures in ${config.outputPath} ?`, default: true, } ]; 複製程式碼