python 爬虫,下载知乎指定问题下全部视频

本篇文章主要介绍如何利用 python 爬虫爬取知乎指定问题下全部视频,其中关键内容为流式文件下载和 tqdm 的使用。

流程梳理

1.对知乎页面进行分析,找到需要的各个参数的位置

2.找到视频链接并分析链接的请求过程,尝试构造视频请求

3.遍历单个回答内的所有视频

4.分析 Ajax 请求,找到问题下各个回答的链接规律

5.遍历整个问题下的所有回答

实施过程

分析页面元素

页面分析其实都是一些老调重弹,可有可无的东西,可直接跳过

首先确定目标页面

5c545b716944a

没错,就是他,我这大半天的快乐源泉

接着分析页面元素,主要是要找到视频的直链,以及点赞的数量

审查元素翻了翻,发现了一个很可疑的链接,点了点发现竟然就是视频的直链

5c545bc96ba56

事出反常必有诈,先来构造问题页面的请求看看返回的是不是和审查元素看到的一样的

1
2
3
4
5
6
7
import requests

UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
headers = {'User-Agent':UA}
base_url = 'https://www.zhihu.com/api/v4/questions/305339708'
response = requests.get(base_url,headers=headers)
print(response.text)

结果返回的数据是这样的

5c546181d484f

竟然没有回答的内容,果然是套路 5c546525d4ace

既然请求的数据和页面元素差别很大,那么可以大致推断回答内容是采用异步加载的

打开开发者工具 XHR 选项卡查看 Ajax 请求,然后往下翻页,能看到不停有请求发生,其中能看到一些包含 video 字段的请求链接

5c546598e3170

把链接打开返回的是一个多层嵌套的字典,其中 play_url 就是视频的直链

5c546268dae93

现在回答里的视频链接出处已经找到了,但回答的链接还没找到,向下继续翻了几页,发现了一个链接包含着 answer 字段的请求,点开后发现又是一个超长的嵌套字典,里面包含着一些页面上的内容,不出意外的话这应该就是回答的链接了。

5c5465f779e4c

回答页面的请求链接是 https://www.zhihu.com/api/v4/questions/271176530/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%2Cis_labeled%3Bdata%5B%2A%5D.mark_infos%5B%2A%5D.url%3Bdata%5B%2A%5D.author.follower_count%2Cbadge%5B%2A%5D.topics&limit=5&offset=10&platform=desktop&sort_by=default 其中最重要的是问题编码,以及 limit 和 offset 字段,问题编码与问题是一一对应的,也就是说可以直接将其他问题的编码代入来进行爬取。limit 是用来限制每次请求返回的回答数量,而 offset 则是用来说明需要请求问题是那些。比如说 limit=5 ,offset=0 就是说要请求第 1-5 条回答

对该地址进行请求并打印返回的内容以及返回的类型,发现返回的内容是字符型不是字典型,于是使用 json.loads() 函数将返回值格式化为字典型。之后在字典中可以提取出总回答数和每篇回答的详细内容,此处也可以使用正则表达式直接提取出所需要的内容,但因为我想要实现根据点赞数来对回答进行区分,所以使用字典对返回的多个答案分别提取。

页面元素的分析到这也就结束了,接下来需要构造请求来下载视频了

视频下载

下载流式文件,requests 中的 stream 设置为 True 就可以了,为什么对于流式文件要单独声明下载方式,这个我有点难以理解,我是这么理解的:当把 stream 设置为 false 时,响应体将会被直接下载,这对于普通小文件来说没有问题,因为伴随着服务器的响应需要的内容差不多也就返回过来了,但对于流式文件来说如果不把文件大小事先告诉程序那么程序是不知道下载应该在何时结束的。而将 stream 设置为 true 时将会推迟响应体的下载,也就是说当使用 r = requests.get(url,stream=True) 时仅有响应头被下载下来了,这时程序可以先获取到文件的大小等相关信息,连接保持开启状态,这就可以在下载时知道文件在什么时候结束,此外这也允许我们根据条件获取内容,例如根据文件大小下载数据,或者是根据文件大小创建一个进度条等等(详见官方文档)。需要注意的是当 stream 设置为 True 时,requests 将不会把连接放回连接池内,除非你消耗完了所有数据,所以在此处必须要让 r 最后 close 以释放连接池。 但在此处又不能使用 with ,with 的原理是在 with 语句体执行前运行 __enter__ 方法,在with语句体执行完后运行 __exit__ 方法,如果语句体内没有这两种方法将不能使用 with ,真坑呐!! 不过这时我们可以使用 contextlib 的 closing 特性,这个特性和函数的装饰器很相似,会自动给函数加上 __enter__()__exit__(),使其满足with的条件,这样就可以使用 with 了。

如此,可写出以下代码:

1
2
3
4
5
6
7
8
9
10
11
import requests
from contextlib import closing

def downoad(url):
with closing(requests.get(url,headers,stream=True)) as r:
with open('1.mp4','wb') as f:
f.write(r)

if __name__ = '__main__':
url=...
download(url)

这个代码已经可以实现视频下载的基本功能了,但是还有一些小瑕疵,比如当遇到大文件时会因为内存不足而出错,以及不知道下载进度会让人很难受。现在就开始着手解决这两个问题。

这里就需要介绍两样东西,一个是 requests 里自带的函数 iter_content() ,还有一个是强大的进度条库 tqdm 。

iter_content()

iter_content() 是 requests 库里用于下载块状文件的一个函数,其主要作用是通过指定 chunk_size 参数的大小使每次都下载 chunk_size (单位是B)大小的数据块写入文件,从而有效避免内存不足的问题(网上有人说这里的写入仍然是在内存里,文件下载后才会一起写入文件里,但这样的话我感觉这个函数就没有存在的意义了哇。。)

tqdm

按照其官方文档的说法,tqdm 可用于任何可迭代的对象,可以有以下几种使用方法

1
2
3
4
5
6
7
8
9
10
11
from tqdm import tqdm

for i in tqdm(['a','b','c',d]):
...

for i in trange(100):
...

pbar = tqdm(["a", "b", "c", "d"])
for char in pbar:
...

其中 trange()是tqdm 和 range 的合体,其等同于 for i in tqdm(range(100))

在这里,tqdm() 是和 iter_content() 一起使用的,iter_content() 将文件分成文件大小( content-length )除以 chunk_size 份,然后迭代遍历

tqdm() 主要用到以下几个参数:

iterable:可迭代对象,也就是你要下载的文件

desc:进度条的前缀

unit:下载速度的单位,默认为 it ( bit )

total:迭代的总次数,这是根据你的 chunk_size 的大小来确定的,比如说你把 chunk_size=1024 ,也就是说每次下载1 k ,那么你的 total 就是 content-lengh/1024 ,同时也要将 unit=’k’ , 以此类推。

以上,就可以推出进阶版的下载代码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import requests
from contextlib import closing
from tqdm import tqdm


UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
headers = {'User-Agent':UA}
url='https://vdn.vzuu.com/SD/0d908846-1e71-11e9-9bb2-0a580a45ca72.mp4?disable_local_cache=1&bu=com&expiration=1549130432&auth_key=1549130432-0-0-6dc591b84cc539e089e2a6c696d0a7d7&f=mp4&v=ali'
with closing(requests.get(url,headers=headers,stream=True))as r:

chunk_size = 1024

content_size = int(r.headers['content-length'])/1024

with open('0.mp4','wb')as f:

for data in tqdm(iterable=r.iter_content(chunk_size=chunk_size),total=content_size,unit='k',desc='0.mp4'):

f.write(data)

下载效果如下

5c55cedf997cf

接下来是整个程序的代码,写的有点烂,还有很多地方需要改善

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import requests
import json
import re
from tqdm import tqdm
from contextlib import closing
import os
from time import sleep


UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
headers = {'User-Agent':UA}

def req(question,offset):
'''
请求回答页面
:param question:
:param offset:
:return:
'''
base_url = 'https://www.zhihu.com/api/v4/questions/'+question+\
'/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%2Cis_labeled%3Bdata%5B%2A%5D.mark_i' \
'nfos%5B%2A%5D.url%3Bdata%5B%2A%5D.author.follower_count%2Cbadge%5B%2A%5D.topics&' \
'limit=5&offset='+str(offset)+'&platform=desktop&sort_by=default'

response = requests.get(base_url,headers=headers)
content = json.loads(response.text)
#返回总回答数,详细信息,问题名称
#有时会报错 outof range
try:
totals, content, title = content['paging']['totals'],content,content['data'][1]['question']['title']
return totals,content,title
except:
print(content['data'])
return None

def details(content):
'''
对每个回答的内容进行处理,并根据点赞数量进行处理
:param content: 包含着回答信息的字典
:return: 返回gif的下载地址和video的播放地址
'''
gifs_list = []
videos_list = []
for item in content['data']:
# 回答点赞数
print("点赞数:",item["voteup_count"])
#当点赞数低于设定值时不进行下载
if item["voteup_count"] < min_vote:
print("忽略")
continue
else:
print("加入队列")
videos = re.findall('href="https://link.zhihu.com/.*?zhihu.*?/(\d+)"',item["content"],re.S)
gifs = re.findall('.*?src="(.*?)"',item["content"],re.S)
gifs_list.append(gifs)
videos_list.append(videos)
#这个返回有点挫,包含着列表的列表,以后得改改
return gifs_list,videos_list


def dir(name,path='C:/Users/C/Desktop/untitled'):
'''
在指定位置新建文件夹,默认位置是项目根目录
:param name: 新建文件夹的名称
:param path: 新建文件夹的上级位置
:return: 返回新建文件夹的路径
'''
# current_path = os.getcwd()
new_path = path +'/'+ str(name)
if os.path.exists(new_path):
print("文件夹已存在")
else:
os.makedirs(new_path)
print("创建了一个叫做"+str(name)+"的文件夹")
return new_path

def video_download(url,video_index=0):
'''
对视频进行下载
:param url: 视频直链
:param video_index: 视频编号
:return:
'''
#检测并生成同一文件夹下视频的名称
while os.path.exists('./'+str(question)+'/'+"videos/"+str(video_index)+".mp4"):
video_index+=1
with closing(requests.get(url,headers=headers,stream=True))as r:
chunk_size = 1024
content_size = int(r.headers['content-length'])/1024

with open('./'+str(question)+'/'+"videos/"+str(video_index)+".mp4",'wb')as f:
for data in tqdm(iterable=r.iter_content(chunk_size=chunk_size),total=content_size,unit='k',desc=str(video_index)+".mp4"):
f.write(data)

def gif_download(url,gif_index=0):
'''
gif图片下载
:param url: gif图片地址
:param gif_index: 图片编号
:return:
'''
while os.path.exists('./'+str(question)+"/gifs/"+str(gif_index)+".gif"):
gif_index+=1
with closing(requests.get(url,headers=headers,stream=True))as r:
chunk_size = 1024
content_size = int(r.headers['content-length'])/1024
with open('./'+str(question)+"/gifs/"+str(gif_index)+".gif",'wb')as f:
for data in tqdm(r.iter_content(chunk_size=chunk_size),total=content_size,unit='k',desc=str(gif_index)+".gif"):
f.write(data)

def get_video_url(videos):
'''
在视频播放页面中提取出视频直链
:param videos: 一个包含着多个视频播放页面链接的列表
:return: 包含多个视频直链的列表
'''
base_url = "https://lens.zhihu.com/api/v4/videos/"
url_list = []
for video in videos:
url_indirect = base_url + str(video)
response = requests.get(url=url_indirect,headers=headers)
# print(url_indirect,response.text)
try:
url_direct = json.loads(response.text)['playlist']['LD']['play_url']
except :
continue
#去重
if url_direct not in url_list:
url_list.append(url_direct)
return url_list

def main():
'''
主函数
:return:
'''
totals,content,title = req(question,0)
#新建问题文件夹,以及问题文件夹下的 gifs 和 videos 文件夹
dir(question)
dir('videos','C:\\Users\\C\\Desktop\\untitled\\'+str(question))
dir('gifs','C:\\Users\\C\\Desktop\\untitled\\'+str(question))

print("问题名称:"+ title)
for offset in range(0,int(totals/5)+1):
sleep(1)
content = req(question,offset*5)[1]
#出错则跳过
if content == None:
continue
gifss,videoss = details(content)
#videoss 和 gifss 是包含着列表的列表
for videos in videoss:
# print(videos)
url_list = get_video_url(videos)
for url in url_list:
# print(url)
video_download(url)
#不知道为什么gif链接是重复的,此处去重
moudle =''
for gifs in gifss:
for gif in gifs:
# 正则表达式选取不准确,此处筛选一下
if 'gif' in gif:
if moudle == gif:
continue
else:
moudle = gif
gif_download(gif)

question = input("问题代号:")
#最小点赞数
min_vote = 0
main()
-----------本文结束感谢您的阅读-----------
0%