快试试这个node小爬虫,一键下载10000张某乎美图送给你

9,909 阅读8分钟

前言

image.png

这是某年某月某日,大概是内心很燥热的季节写的一个小工具,一键下载知乎漂亮妹子图片。至于当时的心情现在我只能在写文章的时候,模拟一下了。

郑重声明:

此小工具只是学习使用,并无任何恶意

此小工具只是学习使用,并无任何恶意

此小工具只是学习使用,并无任何恶意

如果你只是想正经地使用一下,也可以点击这里

开整

八月。湛蓝的天空,悬着火球般的太阳,云彩好似被融化了,消失得无影无踪。没有一丝风,大地活像个大蒸笼。

好热,好烦躁,好无聊。无意间又打开知乎?,首页冒出一个问题给好看的女生拍照是种怎样的体验?,齐刷刷一大摞好看的小姐姐,看的人好生陶醉。作为一个曾经的理工屌丝男,我相信此刻你的想法和我一样,要是可以把她们装进那《学习教程》文件夹就好了。

怎么办?一张张图片右键保存吗?不不不,效率低,鼠标按多了“右手”还疼。差点忘了,我特么是个程序员啊!程序员啊!程序员啊!这种事难道不应该交给程序去干嘛。

"说干就干"

需求

需求很简单:实现自动下载知乎某个帖子所有回答的图片到本地

分析

所以只要知道了以下两点基本就能够完成了。

  1. 图片链接

能够获取帖子下面答题者上传的图片链接,至于所有图片,那就是搜集所有回答者上传的图片链接就可以了

  1. 下载图片

这个暂时猜想是使用成熟的库,我只需要传入图片链接地址,以及图片下载到哪个目录就可以完成下载。如果没找着这样的库,就只能研究原生的nodejs如何做了。

获取图片链接

我们打开chrome浏览器的控制台,发现页面一打开的时候会有很多个请求发出,但是有一个带"answers"请求很可疑,是不是它负责返回答题者的答案呢?

image.png

在验证这个想法之前,我们先不去看这个请求的具体响应内容。我们先点击一下页面上的查看全部 948 个回答按钮,如果猜的对,"answers"请求应该会再次发出,并且返回答题者的答案。

image.png

点击按钮之后发现,“answers”确实再次发出了,并且查看其响应内容大体如下

image.png


{
  data: [
    {
      // xxx
      // 答题者的信息
      author: {
        // ...
      },
      // 答题内容
      content: '"就是觉得太美好了呀<br><br><figure><noscript><img data-rawheight="1080" src="https://pic4.zhimg.com/v2-a7da381efb1775622c497fb07cc40957_b.jpg" data-rawwidth="720" class="origin_image zh-lightbox-thumb" width="720" data-original="https://pic4.zhimg.com/v2-a7da381efb1775622c497fb07cc40957_r.jpg"></noscript></figure>',
      // 帖子描述
      question: {}
      // xxx 等等
    },
    {
      /// xxx
    }
  ],
  paging: {
    // 是否结束
    is_end:false,
    // 是否是刚开始
    is_start:false,
    // 查看下一页内容的api地址
    next: "https://www.zhihu.com/api/v4/questions/49364343/answers?include=data%5B%2A%5D.is_normal%2Cadmin_closed_comment%2Creward_info%2Cis_collapsed%2Cannotation_action%2Cannotation_detail%2Ccollapse_reason%2Cis_sticky%2Ccollapsed_by%2Csuggest_edit%2Ccomment_count%2Ccan_comment%2Ccontent%2Ceditable_content%2Cvoteup_count%2Creshipment_settings%2Ccomment_permission%2Ccreated_time%2Cupdated_time%2Creview_info%2Crelevant_info%2Cquestion%2Cexcerpt%2Crelationship.is_authorized%2Cis_author%2Cvoting%2Cis_thanked%2Cis_nothelp%3Bdata%5B%2A%5D.mark_infos%5B%2A%5D.url%3Bdata%5B%2A%5D.author.follower_count%2Cbadge%5B%3F%28type%3Dbest_answerer%29%5D.topics&limit=5&offset=8&sort_by=default",
    // 上一页内容的api地址
    previous: "https://www.zhihu.com/api/v4/questions/49364343/answers?include=data%5B%2A%5D.is_normal%2Cadmin_closed_comment%2Creward_info%2Cis_collapsed%2Cannotation_action%2Cannotation_detail%2Ccollapse_reason%2Cis_sticky%2Ccollapsed_by%2Csuggest_edit%2Ccomment_count%2Ccan_comment%2Ccontent%2Ceditable_content%2Cvoteup_count%2Creshipment_settings%2Ccomment_permission%2Ccreated_time%2Cupdated_time%2Creview_info%2Crelevant_info%2Cquestion%2Cexcerpt%2Crelationship.is_authorized%2Cis_author%2Cvoting%2Cis_thanked%2Cis_nothelp%3Bdata%5B%2A%5D.mark_infos%5B%2A%5D.url%3Bdata%5B%2A%5D.author.follower_count%2Cbadge%5B%3F%28type%3Dbest_answerer%29%5D.topics&limit=5&offset=0&sort_by=default",
    // 总回答数
    totals: 948
  }
}


从响应中我们拿到总的回答数量,以及当前请求返回的答题者的内容也就是content字段,我们要的图片地址就在noscript标签下的img标签的data-original属性中。所以针对要求1:

我们似乎已经拿到了50%的信息,还有另一半的信息是,我们如何获取所有答题者的内容?,别忘了刚才的响应中还有paging字段, 可以拿到下一次内容的数据

// 是否结束
is_end:false,
// 查看下一页内容的api地址
next: 'https://www.zhihu.com/api/v4/questions/49364343/answers?include=data%5B%2A%5D.is_normal%2Cadmin_closed_comment%2Creward_info%2Cis_collapsed%2Cannotation_action%2Cannotation_detail%2Ccollapse_reason%2Cis_sticky%2Ccollapsed_by%2Csuggest_edit%2Ccomment_count%2Ccan_comment%2Ccontent%2Ceditable_content%2Cvoteup_count%2Creshipment_settings%2Ccomment_permission%2Ccreated_time%2Cupdated_time%2Creview_info%2Crelevant_info%2Cquestion%2Cexcerpt%2Crelationship.is_authorized%2Cis_author%2Cvoting%2Cis_thanked%2Cis_nothelp%3Bdata%5B%2A%5D.mark_infos%5B%2A%5D.url%3Bdata%5B%2A%5D.author.follower_count%2Cbadge%5B%3F%28type%3Dbest_answerer%29%5D.topics&limit=5&offset=8&sort_by=default"',
// 总回答数
totals: ''

query请求部分总共有三个参数

{
  include: 'xxxx', // 这个参数可能是知乎后台要做的各种验证吧
  offset: 3, // 页码
  limit: 5, // 每页内容数量
  sort_by: 'default' // 排序方式
}

所以看起来,咱们把offset设置为0,limit设置为totals的值,是不是就可以拿到所有数据了呢?尝试之后发现,最多只能拿到20个答题者的数据,所以我们还是根据is_end以及next两个响应值,多次请求,逐步获取所有数据吧。

下载在线图片

针对2. 最后一顿google搜索发现还真有这么一个库request,比如要下载一张在线的图片到本地只需要些如下代码


const request = require('request)

request('http://google.com/doodle.png')
  .pipe(fs.createWriteStream('doodle.png'))

到这里1和2两个条件都具备了,接下来要做的就是撸起来,写代码实现了。

预览

在说代码实现之前,我们先来看一下实际下载效果和基本使用吧!!!

爬虫.gif

使用


require('./crawler')({
  dir: './imgs', // 图片存放位置
  questionId: '34078228', // 知乎帖子id,比如https://www.zhihu.com/question/49364343/answer/157907464,输入49364343即可
  proxyUrl: 'https://www.zhihu.com' // 当请求知乎的数量达到一定的阈值的时候,会被知乎认为是爬虫(好像是封ip),这时如果你如果有一个代理服务器来转发请求数据,便又可以继续下载了。
})


proxyUrl先不关注,后面会仔细说明这个字段的作用

实现

点击查看crawler.js

let path = require('path')
let fs = require('fs')
let rp = require('request-promise')
let originUrl = 'https://www.zhihu.com'

class Crawler {
  constructor (options) {
    // 构造函数中主要是一些属性的初始化
    const { dir = './imgs', proxyUrl = originUrl, questionId = '49364343', offset = 0, limit = 100, timeout = 10000 } = options
    // 非代理模式下请求知乎的原始url默认是 https://www.zhihu.com
    this.originUrl = originUrl
    // 代理模式下请求的实际路径, 这里默认也是https://www.zhihu.com
    // 当你的电脑ip被封了之后,可以通过代理服务器,请求知乎,而我们是向代理服务器获取数据
    this.proxyUrl = proxyUrl
    // 请求的最终url
    this.uri = `${proxyUrl}/api/v4/questions/${questionId}/answers?limit=${limit}&offset=${offset}&include=data%5B%2A%5D.is_normal%2Cadmin_closed_comment%2Creward_info%2Cis_collapsed%2Cannotation_action%2Cannotation_detail%2Ccollapse_reason%2Cis_sticky%2Ccollapsed_by%2Csuggest_edit%2Ccomment_count%2Ccan_comment%2Ccontent%2Ceditable_content%2Cvoteup_count%2Creshipment_settings%2Ccomment_permission%2Ccreated_time%2Cupdated_time%2Creview_info%2Crelevant_info%2Cquestion%2Cexcerpt%2Crelationship.is_authorized%2Cis_author%2Cvoting%2Cis_thanked%2Cis_nothelp%3Bdata%5B%2A%5D.mark_infos%5B%2A%5D.url%3Bdata%5B%2A%5D.author.follower_count%2Cbadge%5B%3F%28type%3Dbest_answerer%29%5D.topics&sort_by=default`
    // 是否已经是最后的数据
    this.isEnd = false
    // 知乎的帖子id
    this.questionId = questionId
    // 设置请求的超时时间(获取帖子答案和下载图片的超时时间目前相同)
    this.timeout = timeout
    // 解析答案后获取的图片链接
    this.imgs = []
    // 图片下载路径的根目录
    this.dir = dir
    // 根据questionId和dir拼接的最终图片下载的目录
    this.folderPath = ''
    // 已下载的图片的数量
    this.downloaded = 0
    // 初始化方法
    this.init()
  }

  async init () {
    if (this.isEnd) {
      console.log('已经全部下载完成, 请欣赏')
      return
    }
    // 获取帖子答案
    let { isEnd, uri, imgs, question } = await this.getAnswers()

    this.isEnd = isEnd
    this.uri = uri
    this.imgs = imgs
    this.downloaded = 0
    this.question = question
    console.log(imgs, imgs.length)
    // 创建图片下载目录
    this.createFolder()
    // 遍历下载图片
    this.downloadAllImg(() => {
      // 当前请求回来的所有图片都下载完成之后,继续请求下一波数据
      if (this.downloaded >= this.imgs.length) {
        setTimeout(() => {
          console.log('休息3秒钟继续下一波')
          this.init()
        }, 3000)
      }
    })
  }
  // 获取答案
  async getAnswers () {
    let { uri, timeout } = this
    let response = {}

    try {
      const { paging, data } = await rp({ uri, json: true, timeout })
      const { is_end: isEnd, next } = paging
      const { question } = Object(data[0])
      // 将多个答案聚合到content中
      const content = data.reduce((content, it) => content + it.content, '')
      // 匹配content 解析图片url
      const imgs = this.matchImg(content)

      response = { isEnd, uri: next.replace(originUrl, this.proxyUrl), imgs, question }
    } catch (error) {
      console.log('调用知乎api出错,请重试')
      console.log(error)
    }

    return response
  }
  // 匹配字符串,从中找出所有的图片链接
  matchImg (content) {
    let imgs = []
let matchImgOriginRe = /<img.*?data-original="([^"\?]*?)[?"]/g

    content.replace(matchImgOriginRe, ($0, $1) => imgs.push($1))

    return [ ...new Set(imgs) ]
  }
  // 创建文件目录
  createFolder () {
    let { dir, questionId } = this
    let folderPath = `${dir}/${questionId}`
    let dirs = [ dir, folderPath ]

    dirs.forEach((dir) => !fs.existsSync(dir) && fs.mkdirSync(dir))

    this.folderPath = folderPath
  }
  // 遍历下载图片
  downloadAllImg (cb) {
    let { folderPath, timeout } = this
    this.imgs.forEach((imgUrl) => {
      let fileName = path.basename(imgUrl)
      let filePath = `${folderPath}/${fileName}`

      rp({ uri: imgUrl, timeout })
        .on('error', () => {
          console.log(`${imgUrl} 下载出错`)
          this.downloaded += 1
          cb()
        })
        .pipe(fs.createWriteStream(filePath))
        .on('close', () => {
          this.downloaded += 1
          console.log(`${imgUrl} 下载完成`)
          cb()
        })
    })
  }
}

module.exports = (payload = {}) => {
  return new Crawler(payload)
}



源码实现基本上很简单,大家看注释就可以很快明白。

ip被封了

正当我用写好的crawler.js下载多个帖子下面的图片的时候,程序报了一个这个提示。

系统检测到您的帐号或IP存在异常流量,请进行验证用于确认这些请求不是自动程序发出的"

完蛋了,知乎不让我请求了???

完蛋了,知乎不让我请求了???

完蛋了,知乎不让我请求了???

折腾了半天,最后被当做爬虫给封了。网上找了一些解决方法,例如爬虫怎么解决封IP?

基本上是两个思路

1、放慢抓取速度,减小对于目标网站造成的压力。但是这样会减少单位时间里的抓取量。

2、第二种方法是通过设置代理IP等手段,突破反爬虫机制继续高频率抓取。但是这样需要多个稳定的代理IP。

image.png

继续用本机并且在ip没有发生变化的情况下,直接请求知乎是不可能了,不过我们可以尝试一下2.使用代理服务器。

突然想起自己去年买了一个服务器。就只放了一个resume-native程序。

虽然没法做到像上面两张图一样,哪个代理服务被封,及时再切换另一个代理服务器。但是至少可以通过代理服务器再次下载图片,撸起来。。。

代理IP继续跑

image.png

代理程序proxy.js运行在服务器上,会监测路径为/proxy*的请求,请求到来的时候通过自己以前写的请求转发httpProxy中间件去知乎拉取数据,再返回响应给我们本地。用一张图表示如下

image.png

所以我们原来的请求路径是(为了简化把include这个很长的query参数去除了) www.zhihu.com/api/v4/ques…

经过代理服务器后变成了(其中xxx.yyy.zzz可以是你自己的代理服务器的域名或者ip加端口) xxx.yyy.zzz/proxy/api/v…

这样我们间接地绕过了知乎封ip的尴尬,不过这只是临时方案,终究代理服务器也会被封ip。

结尾

写这个工具只是学习使用,并无其他恶意。如果你只是想正经地使用一下,也可以点击这里