node cli 实现爬虫获取指定数量的图片功能

282 阅读3分钟

前言

一直对这种cli 和爬虫有一种神秘的感觉,今天就按照课程实践一下。记录过程

开始

npm init 

生成一个新的package.json 文件,

抓取百度图片

新建一个image.handle.js 专门处理图片逻辑

// 模仿浏览器的行为访问网站
const superagent = require("superagent");
// 类似jquery 操作DOM元素,比jsDom 更轻量
const cheerio = require("cheerio");
// 分析百度图片的地址,是一个默认被encode转化后的地址
// https://image.baidu.com/search/index?tn=baiduimage
// ie=utf-8&word=%E5%93%88%E5%93%88
const word = '哈哈';
superagent.get(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(word)}`).end((err, res) => {
  if (err) {
    console.log(`访问失败-${err}`)
  } else {
    const htmlText = res.text;
    const $ = cheerio.load(htmlText);
   console.log(htmlText)
  }
})

执行 node image.handler.js 打印结果

1.jpg

盲猜是遇到了百度的反爬策略了。 最大程度模拟浏览器行为!! 咱们把request headers也补上试试。

添加请求头 去浏览器network面板, 把这些请求头的值都复制下来.

...
// copy 请求头的信息
const headers = {
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
  "Accept-Encoding": "gzip, deflate, br",
  "Accept-Language": "zh-CN,zh;q=0.9",
  "Cache-Control": "max-age=0",
  "Connection": "keep-alive",
  "sec-ch-ua-platform": "Windows",
  'sec-ch-ua': '" Not;A Brand";v="99", "Google Chrome";v="97", "Chromium";v="97"',
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36"
}

superagent.get(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(word)}`)
.set(headers)
.end((err, res) => {
  if (err) {
    console.log(`访问失败-${err}`)
  } else {
    const htmlText = res.text;
    const $ = cheerio.load(htmlText);
   console.log(htmlText)
  }
})

执行 node image.handler.js 打印结果,貌似可以了,显示了一些数据。

2.jpg

5、 分析数据,获取图片字段生成列表

匹配规则:/"objURL":"(.*?)",/g 获取到objURL 对应的字段内容

superagent.get(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(word)}`)
.set(headers)
.end((err, res) => {
  if (err) {
    console.log(`访问失败-${err}`)
  } else {
    const htmlText = res.text;
    const $ = cheerio.load(htmlText);
    // 正则匹配对应的图片对象
    const imageMatches = htmlText.match(/"objURL":"(.*?)",/g)
   console.log(imageMatches)
  }
})

得到一个 objURL 的数组对象

3.jpg

继续获取里面的图片

const imageMatches = htmlText.match(/"objURL":"(.*?)",/g).map(item =>{
     const imageUrl =  item.match(/:"(.*?)",/g)
     return RegExp.$1
    })
console.log(imageMatches)

4.jpg

  1. 获取图片的标题列表
...
const htmlText = res.text;
const $ = cheerio.load(htmlText);
const imageMatches = htmlText.match(/"objURL":"(.*?)",/g).map(item =>{
 const imageUrl =  item.match(/:"(.*?)",/g)
 return RegExp.$1
})
const titleMatches = htmlText.match(/"fromPageTitleEnc":"(.*?)",/g).map(item =>{
  const title =  item.match(/:"(.*?)",/g)
  return RegExp.$1
 })
console.log("🚀 ~ file: image.handler.js ~ line 36 ~ .end ~ titleMatches", titleMatches)
  

5.jpg

  1. 提取公共函数 封装一下获取url 和title 一样的代码
// 注意这里要写动态的正则表达式了, 因为咱们要传入动态的key
function getValuesByReg(str, key) {
  const reg = new RegExp(`"${key}":"(.*?)"`,'g')
  const matcheLists = str.match(reg);
  const resList = matcheLists.map(item =>{
    const res = item.match(/:"(.*?)"/g)
    return RegExp.$1
  })
  return resList;
}

superagent.get(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(word)}`)
.set(headers)
.end((err, res) => {
  if (err) {
    console.log(`访问失败-${err}`)
  } else {
    const htmlText = res.text;
    const $ = cheerio.load(htmlText);
    const imageMatches = getValuesByReg(htmlText, 'objURL');
    const titleMatches = getValuesByReg(htmlText, 'fromPageTitleEnc');
  }
})
  1. 创建images目录存储图片
const fs = require("fs")
cosnt path = require("path")
// 创建images 目录存储图片
const dirPath = path.resolve(__dirname, "images")
if(fs.existsSync(dirPath)){
  console.log('images 文件夹已存在,跳过创建文件夹步骤')
  return 
}
fs.mkdirSync(dirPath)
console.log('images 文件夹创建完成')

封装一下,方便创建不同的文件名称

function mkImageDir(pathname) {
  const fullPath = path.resolve(__dirname, pathname);
  if (fs.existsSync(fullPath)) {
      console.log(`${pathname}目录已存在, 跳过此步骤`);
      return;
  }
  fs.mkdirSync(fullPath);
  console.log(`创建目录${pathname}成功`);
}

superagent.get(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(word)}`)
.set(headers)
.end((err, res) => {
  if (err) {
    console.log(`访问失败-${err}`)
  } else {
    const htmlText = res.text;
    const $ = cheerio.load(htmlText);
    const imageMatches = getValuesByReg(htmlText, 'objURL');
    const titleMatches = getValuesByReg(htmlText, 'fromPageTitleEnc');
    // 创建文件目录
    mkImageDir("images")
    
  }
})

9、下载图片到images目录下

// 下载图片到本地
function downloadImage(url, name, index){
  return new Promise((resolve, reject) => {
    const fullpath = path.join(__dirname, 'images', `${index}-${name}.png`)
    // 通过接口访问url 获取内容,写入内容,二进制内容 binary
    superagent.get(url).end((err, res) => {
      if(err) return reject(err);
      fs.writeFile(fullpath, res.body, 'binary', err =>{
        if(err) return reject(err)
        return resolve()
      })
    })
  })
  
superagent.get(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(word)}`)
.set(headers)
.end((err, res) => {
  if (err) {
    console.log(`访问失败-${err}`)
  } else {
    const htmlText = res.text;
    const $ = cheerio.load(htmlText);
    const imageMatches = getValuesByReg(htmlText, 'objURL');
    const titleMatches = getValuesByReg(htmlText, 'fromPageTitleEnc');
    // 创建文件目录
    mkImageDir("images")
  

    imageMatches.forEach((url, index) => {
      downloadImage(url, titleMatches[index], index)
    })
  }
})
  

10、引入一个进度条,看一下下载的进度

安装一个第三方包 cli-progress

yarn add cli-progress --S
// 引入文件 cli-progress
const cliProgress = require("cli-progress");
// 初始化 cliProgress 实例 bar1
const bar1 = new cliProgress.SingleBar({
  clearOnComplete: false
}, cliProgress.Presets.shades_classic);

superagent.get(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(word)}`)
.set(headers)
.end(async (err, res) => {
  if (err) {
    console.log(`访问失败-${err}`)
  } else {
    const htmlText = res.text;
    const $ = cheerio.load(htmlText);
    const imageMatches = getValuesByReg(htmlText, 'objURL');
    const titleMatches = getValuesByReg(htmlText, 'fromPageTitleEnc');
    try {
      // 创建文件目录
      await mkImageDir("images");
      // 总数量
      const total = imageMatches.length;
      // 初始成功的条数
      let successed = 0
      // 进度条开始
      bar1.start(total, 0);
      
      imageMatches.forEach((url, index) => {
        downloadImage(url, titleMatches[index], index).then((resolve)=>{
          successed++
          // 进度条更新
          bar1.update(successed)
        }).then(()=>{
          if(successed == total){
            // 进度条停止
            bar1.stop();
            console.log('恭喜!图片下载完成!')
          }
        })

      })
    } catch(e){
      console.log(e);
    }
    
  }
})

执行 node image.handler.js ,控制台打印信息

7.jpg

11、已存在images先删除, 再创建

function removeDir(pathname) {
  const fullPath = path.resolve(__dirname, pathname);
  const process = require('child_process');
  console.log(`${fullPath} 目录已存在,准备执行删除`)
  // mac 
  // process.execSync(`rm -rf ${fullPath}`);
  // windows
  process.execSync(`rimraf ${fullPath}`);
  console.log(`历史目录${fullPath}删除完成`)
}

// 改造mkImageDir 为promise 
function mkImageDir(pathname) {
  return new Promise((resolve, reject) =>{
    const fullPath = path.resolve(__dirname, pathname);
    if (fs.existsSync(fullPath)) {
      // 删除文件夹
      removeDir(pathname);
    }
    fs.mkdirSync(fullPath);
    console.log(`创建目录${pathname}成功`);
    // 删除成功后已解决
    return resolve();
  })
}

12、把固定的查询关键词哈哈,通过cli来输入关键词。

  • 安装 inquirer, commander yarn add commander inquirer --registry=https://registry.npm.taobao.org
  • 初始化交互的问题(新建index.js).
// index.js
#!/usr/bin/env node
const fs = require('fs');
// 一个用户与命令行交互的工具
const inquirer = require('inquirer');
const commander = require('commander');
const { runImg } = require('./image.handler.js');

// 交互选项
const initQuestions = [{
  type: 'checkbox',
  name: 'channels',
  message: '请选择想要搜索的渠道',
  choices: [
    {
      name: '百度图片',
      value: 'images'
    },
    {
      name: '百度视频',
      value: 'video'
    }
  ]
},{
  type: 'input',
  name: 'keyword',
  message: '请输入想要搜索的关键词'
}]

inquirer.prompt(initQuestions).then(result =>{
  const {
    channels,
    keyword
  } = result;

  for (let channel of channels) {
    switch (channel) {
      case 'images': {
        runImg(keyword)
        break
      }
    }
  }
})

把图片下载函数提取导出 runImg

// image.handler.js
...
function runImg(keyword) {
  superagent
  .get(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(keyword)}`)
  .set(headers)
  .end(async (err, res) => {
    if (err) {
      console.log(`访问失败-${err}`)
    } else {
      const htmlText = res.text;
      const $ = cheerio.load(htmlText);
      const imageMatches = getValuesByReg(htmlText, 'objURL');
      const titleMatches = getValuesByReg(htmlText, 'fromPageTitleEnc');
      try {
        // 创建文件目录
        await mkImageDir("images");
        // 总数量
        const total = imageMatches.length;
        let successed = 0
        // 进度条开始
        bar1.start(total, 0);
        
        imageMatches.forEach((url, index) => {
          downloadImage(url, titleMatches[index], index).then((resolve)=>{
            successed++
            // 进度条更新
            bar1.update(successed)
          }).then(()=>{
            if(successed == total){
              // 进度条停止
              bar1.stop();
              console.log('恭喜!图片下载完成!')
            }
          })

        })
      } catch(e){
        console.log(e);
      }
    }
  })
}

module.exports = {
  runImg
}

13、目前完成的都是请求首页的图片只有30张,如何请求自定义数量的图片数呢?

  • 通过自定义cli 来交互输入数量
  • 分析数量超过一页的数据,其他页数的数据是分页接口获取到的
  • 递归请求,请求自定义数量
  1. 首先要接收用户收入的count参数
// index.js
...
const initQuestions = [{
        type: 'checkbox',
        name: 'channels',
        message: '请选择想要搜索的渠道',
        choices: [{
                name: '百度图片',
                value: 'images'
            },
            {
                name: '百度视频',
                value: 'video'
            }
        ]
    },
    {
        type: 'input',
        name: 'keyword',
        message: '请输入想要搜索的关键词',
    },
    {
        type: 'number',
        name: 'counts',
        message: '请输入要下载图片的数量x, 最小30',
    },
];

inquirer.prompt(initQuestions).then(result => {
    // {"channel":["images"],"keyword":"哈哈哈哈", "counts": 2}
    const {
        channels,
        keyword,
        counts
    } = result;

    for (let channel of channels) {
        switch (channel) {
            case 'images': {
                runImg(keyword, counts);
                break;
            }
        }
    }
})
// 添加一个参数 counts
function runImg(keyword, counts) { }
  1. 写递归请求, 请求自定义数量,涉及到分页,超过首页数据的展示分页数据,走接口拿到数据,未超过的则展示之前的数据

  2. 梳理逻辑

    先把之前的请求封装一下, 因为set header是可以复用的.

// 封装请求地址,接口和页面都需要用到
function request(url){
  return new Promise((resolve, reject)=>{
    superagent
  .get(url)
  .set(headers)
  .end(async (err, res) => {
    if (err) {
      console.log(`访问失败-${err}`)
    } else {
      resolve(res)
    }
  })
  })
}

分页请求递归处理

// 分页封装
async function getImageByPage(start, total, word) {
    let allImages = [];
    while (start < total) {
        const size = Math.min(60, total - start); // 限制每次最大请求60
        const res = await request(`https://image.baidu.com/search/acjson?tn=resultjson_com&ipn=rj&fp=result&queryWord=${encodeURIComponent(word)}&ie=utf-8&oe=utf-8&word=${encodeURIComponent(word)}&pn=${start}&rn=${size}&${Date.now()}=`);
        allImages = allImages.concat(res.text);
        start = start + size;
    }
    return allImages;
}

// runImg 首页获取数据,如果数量超过首页的数据,进行分页数据拼接,并进行最终的图片数据下载
function runImg(keyword, counts) {
  request(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(keyword)}`).then(async (res) =>{
    const htmlText = res.text;
    const $ = cheerio.load(htmlText);
    const imageMatches = getValuesByReg(htmlText, 'objURL');
    const titleMatches = getValuesByReg(htmlText, 'fromPageTitleEnc');
    // 为了方便和后续json数据结合, 修改为同一个数组
    let allImageUrls = imageMatches.map((imgUrl, index) => ({
      imgUrl,
      title: titleMatches[index]
    }));
   
    try {
      // 创建文件目录
      await mkImageDir("images");
      // 弟一页总数量
      const firstPageCount = allImageUrls.length;
      // 拼接分页数据
      if(counts > firstPageCount){
        // 如果要下载的图片数量大于初始的已请求的数量, 就再去请求补足counts
        const restImgUrls = await getImageByPage(firstPageCount, counts, keyword);
        console.log(restImgUrls)
        const formatImgUrls = restImgUrls.filter(item => item.middleURL).map(item =>({
          "imgUrl": item.middleURL,
          "title": item.fromPageTitleEnc
        }))
        allImageUrls = allImageUrls.concat(formatImgUrls)
      } 
        const total = allImageUrls.length
        let successed = 0
        // 进度条开始
        bar1.start(total, 0);
        
        allImageUrls.forEach((item, index) => {
          downloadImage(item.imgUrl, item.title, index).then((resolve)=>{
            successed++
            // 进度条更新
            bar1.update(successed)
          }).then(()=>{
            if(successed == total){
              // 进度条停止
              bar1.stop();
              console.log('恭喜!图片下载完成!')
            }
          })

        })
      
    } catch(e){
      console.log(e);
    }
  })
}

运行一下看看!!!发现 await getImageByPage(firstPageCount, counts, keyword)代码处抛出了错误,写的是禁止爬虫进入

10.jpg

这个时候想到上次禁止爬虫进入是没有模拟浏览器的请求头,那我们就查看接口的请求头需要什么参数。 咱们把能加的参数都加上, 然后看一下request header, 发现其实Accept和之前是不一样的, 需要改一下

// image.handler.js
// 分页接口的请求头内容
const headers2 = {...headers,'Accept': 'text/plain, */*; q=0.01'}

// 封装请求地址,加一个请求头参数,默认headers
function request(url,headers={...headers}){
  return new Promise((resolve, reject)=>{
    superagent
  .get(url)
  .set(headers)
  .end(async (err, res) => {
    if (err) {
      console.log(`访问失败-${err}`)
    } else {
      resolve(res)
    }
  })
  })
}

// 修改分页请求入参
async function getImageByPage(start, total, word) {
  let allImages = [];
  while (start < total) {
      const size = Math.min(60, total - start); // 限制每次最大请求60
      const res = await request(`https://image.baidu.com/search/acjson?tn=resultjson_com&ipn=rj&fp=result&queryWord=${encodeURIComponent(word)}&ie=utf-8&oe=utf-8&word=${encodeURIComponent(word)}&pn=${start}&rn=${size}&${Date.now()}=`, headers2);
      allImages = allImages.concat(JSON.parse(res.text).data);
      start = start + size;
  }
  return allImages;
}

// runImg 首页获取数据,如果数量超过首页的数据,进行分页数据拼接,并进行最终的图片数据下载
function runImg(keyword, counts) {
   // request 添加 参数headers
  request(`http://image.baidu.com/search/index?tn=baiduimage&ie=utf-8&word=${encodeURIComponent(keyword)}`,headers).then(async (res) =>{
    ...
    });
}

遇到图片写入有问题的时候,观察修复图片名称

npm run start 依次输入内容得到结果,小狗的图片创建并加载成功。

11.jpg

14.jpg

可以发现我们已经可以自定义图片数量下载了!!!