前言
八月。透蓝的天空,悬着火球般的太阳,云彩好似被太阳烧化了,也消失得无影无踪。没有一丝风,大地活像一个蒸笼。
好热,好烦躁,好无聊。无意间又打开知乎😋,首页冒出一个问题给好看的女生拍照是种怎样的体验?,齐刷刷一大摞好看的小姐姐,看的人好生陶醉。作为一个曾经的理工屌丝男,我相信此刻你的想法和我一样,要是可以把她们装进那《学习教程》文件夹就好了。
怎么办?一张张图片右键保存吗?不不不,效率低,鼠标按多了“右手”还疼😈。差点忘了,我特么是个程序员啊!程序员啊!程序员啊!这种事难道不应该交给程序去干嘛。
"说干就干"
需求
需求很简单:希望知乎APP自适应用户手机壳颜色。
啊呸呸呸,应该是
需求很简单:实现自动下载知乎某个帖子下所有回答的图片到本地
分析
需求很明确,所以我想只要知道了以下两点基本就能够完成了。
- 图片链接
能够获取帖子下面答题者上传的图片链接,至于所有图片,那就是所有回答者上传的图片链接了
- 下载图片
这个暂时猜想是使用成熟的库,我只需要传入图片链接地址,以及图片下载到哪个目录就可以完成。如果没找着这样的库,就只能研究原生的nodejs如何做了。
针对1,我们打开chrome浏览器的控制台,发现页面一打开的时候会有很多个请求发出,但是有一个带"answers"请求很可疑,是不是它负责返回答题者的答案呢?
在验证这个想法之前,我们先不去看这个请求的具体响应内容。我们先点击一下页面上的查看全部 948 个回答
按钮,如果猜的对,"answers"请求应该会再次发出,并且返回答题者的答案。
点击按钮之后发现,“answers”确实再次发出了,并且查看其响应内容大体如下
{
data: [
{
// xxx
// 答题者的信息
author: {
// ...
},
// 答题内容
content: '"就是觉得太美好了呀<br><br><figure><noscript><img data-rawheight="1080" src="http://img2.jintiankansha.me/get2?src=http://pic4.zhimg.com/v2-a7da381efb1775622c497fb07cc40957_b.jpg" data-rawwidth="720" class="origin_image zh-lightbox-thumb" width="720" data-original="http://img2.jintiankansha.me/get2?src=http://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: ''
复制代码
再结合"answers"这个请求的路径
https://www.zhihu.com/api/v4/questions/49364343/answers?include=data[*].is_normal,admin_closed_comment,reward_info,is_collapsed,annotation_action,annotation_detail,collapse_reason,is_sticky,collapsed_by,suggest_edit,comment_count,can_comment,content,editable_content,voteup_count,reshipment_settings,comment_permission,created_time,updated_time,review_info,relevant_info,question,excerpt,relationship.is_authorized,is_author,voting,is_thanked,is_nothelp;data[*].mark_infos[*].url;data[*].author.follower_count,badge[?(type=best_answerer)].topics&offset=3&limit=5&sort_by=default
其中路径部分
https://www.zhihu.com/api/v4/questions/49364343/answers
,49364343应该就是帖子的id
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,以及如何使用
crawler.js
使用
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。
继续用本机并且在ip没有发生变化的情况下,直接请求知乎是不可能了,不过我们可以尝试一下2.使用代理服务器。突然想起自己去年在搬瓦工买了一个服务器,😀。平时除了用它作为vpn存在访问一些被墙的网站外,就只放了一个resume-native程序。虽然没法做到像上面两张图一样,哪个代理服务被封,及时再切换另一个代理服务器。但是至少可以通过代理服务器再次下载图片,撸起来。。。
另找出路
代理程序proxy.js
运行在服务器上,会监测路径为/proxy*的请求,请求到来的时候通过自己以前写的请求转发httpProxy中间件去知乎拉取数据,再返回响应给我们本地。用一张图表示如下
经过代理服务器后变成了(其中xxx.yyy.zzz可以是你自己的代理服务器的域名或者ip加端口) https://xxx.yyy.zzz/proxy/api/v4/questions/49364343/answers?offset=3&limit=5&sort_by=default
点击查看代理模式gif图左侧是服务器上打印的信息,右侧是本地打印的信息
这样我们间接地绕过了知乎封ip的尴尬,不过这只是临时方案,终究代理服务器也会被封ip。
结尾
好快,一眨眼就下午5点了。这个简单的"爬虫"初试,或者根本就算不上什么爬虫,也有许多不完善的地方,就先放一放啦。天气稍微凉爽了些,该出去走走了。
如果你喜欢,请点一颗星星噢😯
如果你喜欢,请点一颗星星噢😯
如果你喜欢,请点一颗星星噢😯