vlambda博客
学习文章列表

扒一扒node爬虫框架node-crawler

node-crawler目标打造成Node社区最强大和流行的爬虫/内容抽取工具库,且支持生产环境。

特性:

  • 服务端DOM和自动jQuery注入,使用Cheerio(默认)或JSDOM

  • 可配置的连接池大小和重试次数

  • Control rate limit 控制请求间隔时间

  • 支持设置请求队列优先级

  • forceUTF8模式可让爬虫处理字符集编码探测和转换

  • 兼容Node 4.x及以上版本

1 基本使用


# npm install crawler

# 01.movie.js
const Crawler = require('crawler');

const c = new Crawler({
   //每次请求爬取的时间间隔
   rateLimit: 2000,
   //最大连接数量
   maxConnections: 10,
   //爬取成功的回调函数(通用)
   callback: (error, res, done) => {
       if (error) {
           console.log(error);
      } else {
           const $ = res.$;
           const $searchList = $('.search-list>div');
           $searchList.map((index,item)=>{
               console.log($(item).find('.pic-pack-outer>h3').text())
          })
      }
       done();
  }
});

//大片首页
c.queue('https://www.1905.com/vod/list/n_1/o4p1.html');
//微电影 系列电影
c.queue(['https://www.1905.com/vod/list/n_1_c_922/o4p1.html','https://www.1905.com/vod/list/n_2/o3p1.html']);
//纪录片
c.queue([{
   uri: 'https://www.1905.com/vod/list/c_927/o3p1.html',
   jQuery: true,
   //自定义参数:可以在callback中的res.options.parameter中获取
   parameter: '参数',
   //自定义爬取的回调函数
   callback: (error, res, done) => {
       if (error) {
           console.log(error);
      } else {
           console.log(res.options.parameter)
           const $ = res.$;
           const $searchList = $('.search-list>div');
           $searchList.map((index,item)=>{
               console.log($(item).find('.pic-pack-outer>img').attr('src'))
          })
      }
       done();
  }
}]);

2 下载文件


//下载文件
const c2 = new Crawler({
   encoding: null,
   jQuery: false,
   callback: (err, res, done) => {
       if (err) {
           console.error(err.stack);
      } else {
           fs.createWriteStream("data\\"+res.options.filename).write(res.body);
      }
       done();
  }
});

c2.queue({
   uri: 'https://image11.m1905.cn/uploadfile/2019/0401/thumb_1_150_203_20190401021843670441.jpg',
   filename: 'thumb_1_150_203_20190401021843670441.jpg'
});

3 请求预处理

在每次发送请求之前要做的事情可以在preRequest方法中设置(直接发送请求不会触发该方法)


const c = new Crawler({
   preRequest: (options, done) => {
       //这里的options将会传递给request模块
       console.log(options);
  //当done方法调用的时候,请求将被发送
  done();
  },
   callback: (err, res, done) => {
       if (err) {
     console.log(err);
  } else {
     console.log(res.statusCode);
  }
  }
});

c.queue({
   uri: 'http://www.google.com',
   //这里的preRequest将会覆盖new Crawler中定义的preRequest
   preRequest: (options, done) => {
       setTimeout(() => {
     console.log(options);
     done();
  }, 1000);
  }
});

4 直接发送请求

如果想要直接发送请求而不经过Crawler的任务队列,可以使用direct方法(其配置和queue一样)。


crawler.direct({
   uri: 'http://www.google.com',
   // default to true, direct requests won't trigger Event:'request'
   skipEventRequest: false,
   callback: (error, response) => {
       if (error) {
           console.log(error)
      } else {
           console.log(response.statusCode);
      }
  }
});

5 常用事件

a) Event: 'schedule'

  • options

当一个任务被加到计划时触发.


crawler.on('schedule',function(options){
   options.proxy = "http://proxy:port";
});

b) Event: 'request'

  • options

当爬虫准备好发送请求时触发.

如果你想在发出请求之前的最后阶段改变配置,可以监听这个事件。


crawler.on('request',function(options){
   options.qs.timestamp = new Date().getTime();
});

c) Event: 'drain'

当队列为空时触发。


crawler.on('drain',function(){
   // 执行一些操作,如,释放数据库连接。
   db.end(); // 关闭MySQL连接。
});

d) crawler.queue(uri|options)

  • uri String

  • options

将任务加入队列并等待执行。

e) crawler.queueSize

  • Number

队列数量,该属性为只读。

6 配置指南

你可以将这些配置发给 Crawler() 构造器,让它们成为全局配置,或者自定义 queue() 的请求已覆盖全局配置。

这个配置列表在mikeal的request项目配置的基础上做了扩展,并且会直接发送给 request() 方法。

a) 基本请求配置项

  • uri: String 你想爬取的网页链接.

  • timeout : Number 单位是毫秒 (默认 15000).

  • 支持mikea的request所有配置

b) 回调

  • callback(error, res, done): 请求完成后会被调用

    • res.statusCode: Number HTTP status code. E.G.200

    • res.body: Buffer | String HTTP返回内容,可能是HTML页面、纯文本或XML文档。

    • res.headers: Object HTTP请求的返回头

    • res.request:Request Mikeal的 Request 的实例,以取代 http.ClientRequest

    • res.request.uri: urlObject解析后的URL实体

    • res.request.method: String HTTP request method. E.G. GET

    • res.request.headers: Object HTTP request headers

    • res.options: Options配置项

    • $: HTML和XML的选择器

    • error: Error

    • res: 请求的回应,包括 $options  

    • done: Function 回调中要做的事情做完后需要调用这个

c) 计划任务选项

  • maxConnections: Number 连接池大小 (默认 10).

  • rateLimit: Number 每条请求之间的间隔时间,单位毫秒 (默认 0).

  • priorityRange: Number 可接受的优先级数值,最小为0 (默认 10).

  • priority: Number 当前请求的优先级 (默认 5).

d) 重试选项

  • retries: Number 请求失败后的重试次数 (默认 3),

  • retryTimeout: Number 重试前等待的时间,单位毫秒 (默认 10000),

e) 服务端DOM配置

  • jQuery: Boolean|String|Object 如果设置为true,使用cheerio和默认配置来注入爬取内容。或使用解析选项自定义cheerio. 当返回false时停止注入jQuery选择器。如果在你的项目中存在内存泄漏,使用一个替代的解析器"whacko"来避免。(默认 true)

d) 字符集编码

  • forceUTF8: Boolean 。如果设置为true,爬虫将从HTTP请求头中获取字符集或从HTML中获取meta tag,并且将它转换到UTF8,不用再担心编码问题了(默认 true)

  • incomingEncoding: String 。当设置 forceUTF8: true 时可自行设置接收内容的编码 (默认 null),爬虫就不用自己检测编码了。如 incomingEncoding : 'windows-1255'

e) 缓存

  • skipDuplicates: Boolean 设置为true时,跳过已爬取过的URI,甚至不触发 callback()(默认 false)。不推荐改动,更好的做法是在爬虫之外使用seenreq进行处理。

f) http头

  • rotateUA: Boolean 当为true时, userAgent 应该是数组,并进行轮换 (默认 false)

  • userAgent: String|Array, 如果 rotateUA 为 false, 但 userAgent 是一个数组, 爬虫将使用第一个值。

  • referer: String 当为真时设置HTTP的 referer header

  • headers: Object Raw key-value of http headers

7.爬取电影(后端渲染)


//1.通过起始地址获取所有分类地址
//2.通过分类地址获取分类地址下的所有影片地址
//3.通过每一个影片地址获取影片的详情数据


const Crawler = require('crawler');
const fs = require('fs')

const c = new Crawler({
   //每次请求爬取的时间间隔
   rateLimit: 100,
   //最大连接数量
   maxConnections: 10,
   //爬取成功的回调函数(通用)
   callback: (error, res, done) => {
       if (error) {
           console.log(error);
      } else {

      }
       done();
  }
});



//获取url下所有的分类数据
function getAllCategories(url) {
   return new Promise((resolve, reject) => {
       c.queue([{
           uri: url,
           jQuery: true,
           callback: (error, res, done) => {
               if (error) {
                   reject(error)
              } else {
                   const $ = res.$;
                   const $indexSearchR = $(".search-index-R");
                   const categoryArr = []
                   $indexSearchR.map((index, item) => {
                       const $item = $(item);
                       const $categories = $item.find("a");
                       $categories.map((index, category) => {
                           let href = $(category).attr('href');
                           if (href == "javascript:void(0);") {
                               href = $(category).attr('onclick').split("'")[1]
                          }
                           const categoryobj = {
                               name: $(category).text(),
                               href: href
                          }
                           categoryArr.push(categoryobj)
                      })
                  })
                   resolve(categoryArr)
              }
               done();
          }
      }]);
  })
}

//获取某一个分类下的影片信息
function getMovieByCategoriy(category) {
   return new Promise((resolve, reject) => {
       c.queue({
           uri: category.href,
           jQuery: true,
           callback: (error, res, done) => {
               if (error) {
                   reject(error)
                   return;
              }
               const $ = res.$;
               const $searchList = $(".search-list>div");
               const movies = []
               $searchList.map((index, item) => {
                   const name = $(item).find('.pic-pack-outer>h3').text()
                   const href = $(item).find('.pic-pack-outer').attr('href')
                   movies.push({
                       name,
                       href
                  })
              })
               resolve(movies)
               done();
          }
      })
  })
}

//获取所有分类下的影片信息 async修饰的方法会返回一个Promise
async function getAllMovieByCategories(categoryArr) {
   const allMovies = [];
   for (let i = 0; i < categoryArr.length; i++) {
       let category = categoryArr[i];
       let movies = await getMovieByCategoriy(category);
       console.log(movies)
       allMovies.push(...movies)
  }
   return allMovies;
}


//获取指定movie(一个)的影片信息
function getMovieMessage(movie){
   return new Promise((resolve, reject) => {
       c.queue({
           uri: movie.href,
           jQuery: true,
           callback: (error, res, done) => {
               if (error) {
                   reject(error)
                   return;
              }
               const $ = res.$;
               const $movieInfo = $('.playerBox-info-leftPart');
               const name = $movieInfo.find('.playerBox-info-title>.playerBox-info-cnName').text();
               const ename = $movieInfo.find('.playerBox-info-title>.playerBox-info-enName').text();
               const year = $movieInfo.find('.playerBox-info-title>.playerBox-info-year').text()
               const grade = $movieInfo.find('.playerBox-info-title .playerBox-info-grade').html()
               const intro = $movieInfo.find('.playerBox-info-intro>#playerBoxIntroCon').text()
               const movieInfo = {
                   name,
                   ename,
                   year,
                   grade,
                   intro
              }
               resolve(movieInfo)
               done();
          }
      })
  })
}

//获取所有电影的影片信息
async function getAllMovieMessageByMovies(movies){
   const allMovieMessages = [];
   for (let i = 0; i < movies.length; i++) {
       let movie = movies[i];
       let movieMsg = await getMovieMessage(movie);
       allMovieMessages.push(movieMsg)
       console.log(movieMsg)
  }
   return allMovieMessages;
}

//执行爬取网站请求
getAllCategories("https://www.1905.com/vod/list/n_1/o4p1.html")
  .then((res) => {
       return getAllMovieByCategories(res)
  })
  .then((res)=>{
       console.log(res,"8888888888888")
       return getAllMovieMessageByMovies(res)
  })
  .then((res)=>{
       console.log(res)
  })

//在实际爬取过程中,可以先将分类数据爬取入库,然后下次爬取的时候直接读取分类数据,然后进行下一步的爬取

8.反爬虫策略

爬虫面临的最大挑战就反爬虫。在爬网站的内容的时候并不是一爬就可以了,有时候就会遇到一些网站的反爬虫,会经常遇到返回一些404,403或者500的状态码,或者直接返回的是混淆后的密文,根本无法提取出其中的有效信息。下面列举常见的反爬虫方式以及解决方案。

8.1 请求头HTTP-Header屏蔽

Headers反爬虫是最常见的反爬虫策略。由于正常用户访问网站时是通过浏览器访问的,所以目标网站通常会在收到请求时校验Headers中的User-Agent字段,如果不是携带正常的User-Agent信息的请求便无法通过请求。还有一部分网站为了防盗链,还会校验请求Headers中的Referer、COOKIE信息。

遇到了这类反爬虫机制,需要根据实际爬取的网站进行定制化的模拟来符合正常访问,需要工程师去分析判断网站Http-header的验证方式,并针对性地进行调整。

8.2 验证码屏蔽方式

验证码几乎是现在所有站点对付爬虫的手段,验证码包括图像验证码、短信动态密码、语音密码、USB加密狗等方式。简单的验证码可以通过OCR技术实现验证码识别,而遇到复杂的验证机制,依托现有技术很难解决,往往需要人工来进行辅助验证。只有通过平台的验证码后,才能访问到所需要的信息。

8.3 封IP、限制IP访问频率

如果一个固定的IP在短暂的时间内快速大量的访问一个网站,应用后台往往会自动判断是机器爬虫。带来的结果就是直接将这个IP给封了,爬虫程序自然也就做不了什么了。

8.4 动态加载技术实现反爬虫

对于一些动态网页,利用JS动态填充技术,实现内容的动态填充。如果简单解析Http会发现返回的信息为空,而真正有用的信息则影藏在JS文件中,或者通过ajax请求得到。这种情况需要进行完全模拟终端用户的正常访问请求,以及浏览动作,在浏览器端重现用户行为,才能够获取到所需要的信息。

8.5 内容的加密与混淆

数据加密混淆的方式有很多种,国内主流的网站(淘宝、天猫、微博、新浪等)基本都采用了特殊的数据混淆加密的收到,本地数据均为混淆后的数据无法直接采集使用,需要开发人员进行分析,找出混淆的规律,再利用解析器来提取出有效信息。

还有一种加密是服务器端加密,在客户端或者前端解密,比如想抓app端的一些数据,发现接口返回的结果都是加密后的密文,需要反编译apk文件获得源码读源码,找到加密算法、密匙进行反向工程。

8.6 增量与存量内容对比与整合

针对一些长期的爬取需求,会定时进行爬取,而如何有效的爬取出其中新增和更新的内容,以及如何将爬取下来的增量内容与存量的内容进行对比分析、整合形成一个完整数据集。

8.7 不定期的应用变更、上线反爬虫措施

爬虫和反爬虫作为相生相克的死对头,无论爬虫多厉害,都是能被复杂的反爬虫机制发现,从而采用更为先进、难以破解的反爬虫措施。国内主流的网站(淘宝、天猫、微博、京东等)为了防止被爬虫,会不定期地对反爬虫措施进行变更,使得原有的爬虫程序完全失效,开发者也将不得不重新开发一套新的爬虫程序,以适应爬取数据需求。

9.使用代理

使用limiter控制爬取频率。所有提交到limiter的任务都需要遵守rateLimit 和maxConnections 的限制。rateLimit是两个任务之间的最小间隔,maxConnections是最大的并发数。limiters之间是互相独立的。一个通常的用例是为不同的代理设置不同的limiter。另外值得一提的是,当rateLimit设置为非0的值时,maxConnections 的值将被强制为1


const Crawler = require('crawler');
const fs = require('fs')

const c = new Crawler({
   //每次请求爬取的时间间隔
   rateLimit: 2000,
   //最大连接数量
   maxConnections: 10
});


c.queue({
   uri: 'https://www.1905.com/vod/list/n_1/o4p1.html',
   limiter: 'proxy_1',
   proxy: 'http://1.181.48.68:3128',
   //爬取成功的回调函数(通用)
   callback: (error, res, done) => {
       if (error) {
           console.log(error);
      } else {
           const $ = res.$;
           const $searchList = $('.search-list>div');
           $searchList.map((index, item) => {
               console.log($(item).find('.pic-pack-outer>h3').text(),"proxy_1")
          })
      }
       done();
  }
})
c.queue({
   uri: 'https://www.1905.com/vod/list/n_1_c_922/o4p1.html',
   limiter: 'proxy_2',
   proxy: 'http://129.204.182.65:9999',
   //爬取成功的回调函数(通用)
   callback: (error, res, done) => {
       if (error) {
           console.log(error);
      } else {
           const $ = res.$;
           const $searchList = $('.search-list>div');
           $searchList.map((index, item) => {
               console.log($(item).find('.pic-pack-outer>h3').text(),"proxy_2")
          })
      }
       done();
  }
})
c.queue({
   uri: 'https://www.1905.com/vod/list/n_2/o3p1.html',
   limiter: 'proxy_3',
   proxy: 'http://113.195.202.183:9999',
   //爬取成功的回调函数(通用)
   callback: (error, res, done) => {
       if (error) {
           console.log(error);
      } else {
           const $ = res.$;
           const $searchList = $('.search-list>div');
           $searchList.map((index, item) => {
               console.log($(item).find('.pic-pack-outer>h3').text(),"proxy_3")
          })
      }
       done();
  }
})
c.queue({
   uri: 'https://www.1905.com/vod/list/c_927/o3p1.html',
   limiter: 'proxy_4',
   proxy: 'http://120.222.17.151:3128',
   //爬取成功的回调函数(通用)
   callback: (error, res, done) => {
       if (error) {
           console.log(error);
      } else {
           const $ = res.$;
           const $searchList = $('.search-list>div');
           $searchList.map((index, item) => {
               console.log($(item).find('.pic-pack-outer>h3').text(),"proxy_4")
          })
      }
       done();
  }
})

# 网上的免费代理,绝不大部分都是不能用的,如有需求,可以购买代理

10.爬取音乐(前后端分离)

https://www.vfinemusic.com/music-library

10.1 爬取下载音乐


const Crawler = require('crawler');
const path = require('path')
const fs = require('fs')

const c = new Crawler({
   //每次请求爬取的时间间隔
   rateLimit: 2000,
   //最大连接数量
   maxConnections: 10,
});


function getMusicList() {
   const musics = []
   return new Promise((resolve, reject) => {
       c.queue({
           jQuery: false,
           uri: 'https://www.vfinemusic.com/v1/works',
           qs: {
               format: 'json',
               page: 1,
               page_size: 24,
               status: "ONLINE"
          },
           //自定义爬取的回调函数
           callback: (error, res) => {
               if (error) {
                   reject(error)
              } else {
                   const results = JSON.parse(res.body).results;
                   results.map((item) => {
                       musics.push({
                           name: item.name,
                           producer: item.producer.name,
                           url: item.preview
                      })
                  })
                   resolve(musics)
              }
          }
      })
  })
}


const c2 = new Crawler({
   //设置encoding为null,不会将服务器返回内容用字符串进行编码
   encoding: null,
   jQuery: false,

});

function downLoadOneMusic(url, name) {
   const extname = path.extname(url);
   return new Promise((resolve, reject) => {
       c2.queue({
           uri: url,
           filename: name + extname,
           callback: (err, res, done) => {
               if (err) {
                   reject(err);
              } else {
                   fs.createWriteStream("data\\musics\\" + res.options.filename).write(res.body);
                   resolve()
              }
               done();
          }
      });
  })
}

async function downLoadAllMusic(musics) {
   for (let i = 0; i < musics.length; i++) {
       let result = await downLoadOneMusic(musics[i].url, musics[i].name);
       console.log("下载" + musics[i].name + "完毕")
  }
   return "下载完毕"
}


getMusicList()
  .then((musics) => {
       console.log(musics)
       return downLoadAllMusic(musics)
  })

10.2 爬取我的订单

const Crawler = require('crawler');

const c = new Crawler({
  //每次请求爬取的时间间隔
  rateLimit: 2000,
  //最大连接数量
  maxConnections: 10,
});

/*
注意点:
  1.需要使用fiddler抓包工具分析接口
  2.借助postman来测试请求
  3.借助浏览器来登录注销查看接口地址

主要流程:
  1.调用登录接口(post请求),获取用户登录的cookie信息
  2.根据登录后的cookie,调用user/profile接口,获取用户信息(包含uuid)
  3.在获取订单数据的时候,调用auth接口,根据uuid获取访问的tocken凭证
  4.根据tocken凭证和uuid,调用order接口,获取用户对应的订单信息
*/

//模拟登录的方法:返回登录之后的cookies信息
function doLogin() {
  return new Promise((resolve, reject) => {
      c.direct({
          uri: 'https://www.vfinemusic.com/v1/users/login',
          method: "POST",
          headers: {
              "Content-Type": "application/json; charset=UTF-8"
          },
          body: JSON.stringify({
              "email": "[email protected]",
              "phone": "",
              "password": "abcd1234",
              "device_id": "vf1621510105960-39532"
          }),
          //自定义爬取的回调函数
          callback: (error, res) => {
              if (error) {
                  console.log(error);
                  reject(error)
              } else {
                  resolve(res.headers['set-cookie'])
              }
          }
      })
  })
}

let userInfo = {}
//根据cookies获取用户信息(里面包含uuid,后面在获取订单数据的时候需要根据uuid来生成tocken,再根据tocken获取订单数据)
function getUserId(cookies) {
  console.log(cookies);
  return new Promise((resolve, reject) => {
      c.direct({
          jQuery: false,
          uri: 'https://www.vfinemusic.com/v1/users/profile',
          headers: {
              "Cookie": `${cookies[0]};${cookies[1]}`,
          },
          //自定义爬取的回调函数
          callback: (error, res) => {
              if (error) {
                  reject(error)
              } else {
                  console.log(JSON.parse(res.body))
                  userInfo = JSON.parse(res.body)
                  resolve(JSON.parse(res.body))
              }
          }
      })
  })
}


//根据用户信息获取access_tocken
function getAcceccTocken(uuid) {
  return new Promise((resolve, reject) => {
      c.direct({
          jQuery: false,
          uri: `https://www.vfinemusic.com/php/v1/auth/?user_id=${uuid}`,
          //自定义爬取的回调函数
          callback: (error, res) => {
              if (error) {
                  reject(error)
              } else {
                  console.log(JSON.parse(res.body))
                  resolve(JSON.parse(res.body).access_token)
              }
          }
      })
  })
}


//根据access_tocken获取用户的订单数据
function getOrderList(tocken) {
  return new Promise((resolve, reject) => {
      c.direct({
          jQuery: false,
          uri: 'https://www.vfinemusic.com/php/v1/get_charge_orders/',
          qs: {
              user_id: userInfo.uuid,
              page: 1,
              access_token: tocken
          },
          //自定义爬取的回调函数
          callback: (error, res) => {
              if (error) {
                  reject(error)
              } else {
                  console.log(JSON.parse(res.body))
                  resolve(JSON.parse(res.body))
              }
          }
      })
  })
}



doLogin()
  .then((cookies) => {
      return getUserId(cookies)
  })
  .then((userInfo) => {
      return getAcceccTocken(userInfo.uuid)
  })
  .then((tocken)=>{
      return getOrderList(tocken)
  })