前言

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。

结尾

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