手把手教你用node撸一个简易的headless爬虫cli工具

2,069 阅读7分钟

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2018/10/17/16681b5b59f749e0~tplv-t2oaga2asx-image.image

众所周知,node功能很强大,为前端提供了更多的可能。今天,就跟大家分享一下我是如何用node写一个headless爬虫的。原文链接leeing.site/2018/10/17/…

用到的工具

  • puppeteer
  • commander
  • inquirer
  • chalk

下面就给大家讲一下这些工具都有什么作用

puppeteer

headless爬虫主要靠它。它可以模拟用户打开网页的过程,但是并没有打开网页。写过自动化测试的同学应该对这个会比较熟悉,因为用它爬虫的过程跟自动化测试的过程几乎是一样的。

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方法模拟进入百度图片页面。这时其实就是跟我们真实打开浏览器进入百度图片是一样的,只不过因为我们是headless的,所以我们无法感知打开浏览器的过程。

然后我们需要设置一下浏览器的宽度(想象一下),不能太大,也不能太小。太大会触发百度反爬虫机制,导致我们爬下来的图片是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下的图片呢? 这里我们还是用到了nodefs模块,首先利用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();
}

我们引入commanderinquirerprogram.command方法是为我们生成命令名的,alias是该命令的缩写,description是该命令的描述,action是该命令要做的事情。

我们首先用command生成了两个命令,searchclean,接着可以看到,我们在action中用了inquirerinquirer的提问是一个异步的过程,所以我们也一样用了asyncawaitinquirer接收一个问题数组,里面包含问题的type、name、message和验证方法等,具体的可以参考inquirer的文档。我们这里的问题如下,这里返回了两个数组,一个是用于输入关键字的时候的,一个是用于清空图片时确认的。提问数组中会验证是否有填写关键字,如果没有,则不会继续下一步并提示你该输入关键字,否则就正式开始爬虫流程。删除确认数组就是简单的一个确认,如果确认了,则开始删除图片。最后,用program.parse将命令注入到nodeprocess.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,
    }
];

项目下载

npm i get_picture -g

参考链接