爬虫图解:轮子哥关注了哪些人


这不是 UC 式的男默女泪震惊文。 ——作者


前言:

本文以知乎大V轮子哥关注的用户列表作为爬虫对象,爬取每个用户的url_token、昵称、性别、该用户关注其他用户的数量、回答数、文章数、一句话介绍和头像的链接。并将这些数据持久化存储在数据库中,将用户头像下载到本地。最后简单使用 Python 中的 matplotlib 库将爬取的部分信息可视化。

爬虫思路:

打开轮子哥知乎上关注的用户列表 ,调出Chrome的开发者工具(Developer Tools),重新刷新页面,选择『XHR』,第一个就是我们需要的请求信息:

我们观察 请求头 Headers,得知实际该页面的数据请求URL为:

分析这个URL的字段,其中excited-vczh为轮子哥的url_token,是知乎识别唯一用户的字段,followees代表是轮子哥的关注列表,如果是followers,代表轮子哥的粉丝列表,不难发现URL中间部分包含data、answer_count、articles_count、gender、follower_count等字段信息,而这些信息正是我们想要的。再看最后的两个字段是offset,代表用户列表页起始数量,limit代表每页返回用户信息的数量。

点击『Preview』,可以看到格式化后响应的Json数据,源数据可以在『Response』查看。

查看 Json 数据结构:

根据字段见名知意,其中 avatar_url 就是用户头像链接,avatar_url_template 是头像的路由模板,在该链接中有个 {size} 参数,当我们点击查看网页源码发现,size 为 xll 表示头像大图,所以在程序中,我们每爬到这个链接都需要把 {size} 参数替换成 xll,这样就可以爬取用户较为清楚的头像,这个 Json 中还有个 badge 字段,表示该用户所获得的徽章,也就是在知乎的成就,比如各个领域的高质量答主,但不是本次爬虫所关心的。每一次请求,都是20条用户数据:

那么问题来了,这时我们确实能够爬虫这20个被轮子哥关注的用户的一些数据,但是轮子哥关注的用户肯定不止20个,那么其他用户的数据该怎么爬?继续观察 Json 数据:

有个『paging』字段,该字段下封装了『is_end』『totals』『previous』『is_start』『next』五个参数,分别代表:

  • 是否是轮子哥关注用户列表的最后一页URL

  • 轮子哥一共关注了多少人

  • 当前关注用户列表页的前一页的URL

  • 是否是轮子哥关注用户列表的第一页URL

  • 当前关注用户列表页的下一页的URL

我们找到轮子哥关注列表的第一页开始爬虫,只需要每页爬虫20个用户数据,就获得『next』的值,即下一页URL,可以继续爬虫,直至所有轮子哥关注的用户全部爬取完成。

实现方法:

在网页浏览时,我们可以不用登录知乎帐号就可以查看知乎某个用户关注了哪些人,当我们在没有登录知乎的时候,把上述真正的用户列表URL在地址栏访问时,其实是查看不了的:

会显示 error,错误原因是无效授权请求,即对真正的 URL 访问是需要登录的,这样做是避免爬虫,增加爬虫的小障碍,所以程序中要模拟登录,需要模拟成浏览器,避免别服务端发现我们是爬虫把我们拒之门外。验证一下,当我们在网页中登录进知乎,再次输入这个真正的 URL 链接:

说明登录进知乎,我们得到我们想要的数据,这些数据和之前在 Chrome 开发者模式『Preview』 中的格式化的数据是一模一样的。

这是非常小规模的爬虫,纯粹使用 urllib 库足以完成需求,对于模拟登录,可以查找登录的实际请求 URL ,根据规则用键值对的形式封装帐号、密码形成 post 请求的数据。另一种是直接是在浏览器中登录后,把整个 cookie 放进程序的请求头中,当然,为了更加逼真体现我们是合法的请求,把『user-agent』『accept』『accept-language』等信息也放进请求中。在爬虫的同时,也将轮子哥关注的用户的信息进行 Mysql 数据库本地持久化,以便后续的数据可视化,同时也将轮子哥关注的用户的头像存在本地。

结果:

  • 数据持久化

  • 头像爬取

  • 数据可视化

轮子哥带逛闻名遐迩,今日一见,名不虚传。轮子哥关注的用户中接近三分之二是女性用户,接近三分之一是男性用户。

被轮子哥关注的用户中,这些用户的粉丝数量分布还是很均匀的,他们的粉丝数在 0-1k 范围内,占到轮子哥关注的用户的一半比例,轮子哥关注的大于十万粉的用户也有72位,其中轮子哥关注的用户中粉丝数最多是张佳玮老师,接近160w粉。

轮子哥关注的用户的回答数分布如上图,其中最高的三个柱子是回答了:1~5、51~100、11~20,其中1~5个回答数人数最多,有410个用户。其中,回答数11~50和回答数大于51的人数是差不多的, 这个是很有意思的情况。

在轮子哥关注的用户中,有82.49%的用户个人信息中含有『一句话介绍』,17.51%的用户是没有『一句话介绍』。

轮子哥关注的用户中,有接近80%的人是有文章输出的,而这个比例,接近之前含有『一句话介绍』的用户数量。

代码:

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
# -*- coding: utf-8 -*-
import urllib.request
import urllib.error
import http.cookiejar
import json
import time
import pymysql
import matplotlib.pyplot as plt

# 创建连接
conn = pymysql.connect(host='127.0.0.1', port=3306, user='root', passwd='root', db='spider', use_unicode=True, charset="utf8")
cursor = conn.cursor()
cjar = http.cookiejar.CookieJar()
headers = {
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
# 注意浏览器里的encoding 可能是gzip,deflate 而这里需要utf-8 避免不能解码
'accept-encoding': 'utf-8',
'accept-language': 'zh-CN,zh;q=0.9',
'referer': 'https://www.zhihu.com/',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.162 Safari/537.36',
'Cookie': '这里填写你自己的登录知乎的cookie'
}
headers_all = []
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cjar))
for k, v in headers.items():
item = (k, v)
headers_all.append(item)
opener.addheaders = headers_all
urllib.request.install_opener(opener)


def followers(url):
content = urllib.request.urlopen(url).read().decode('utf-8')
print(content)
j = json.loads(content)
# 如果data数据为空说明已爬取最后一页,结束爬虫
# 虽然响应数据中有is_end 判断,但是在倒数第二页有is_end为true,所以不好直接用is_end字段判断是否爬取完成,否则会造成最后一页爬取不到
data = j['data']
if not data:
return
images = {}
for d in data:
token = d['url_token']
name = d['name']
if d['gender'] == 1:
gender = '男'
elif d['gender'] == -1:
gender = '性别未知'
else:
gender = '女'
follower_count = d['follower_count']
avatar_url = d['avatar_url_template']
answer_count = d['answer_count']
articles_count = d['articles_count']
headline = d['headline']
# 将头像url模板中的{size}设置成xll
avatar_url = avatar_url.replace('{size}', 'xll')
# 数据存储
images[name] = avatar_url
image_name = '/Users/rundouble/Desktop/excited-vczh_followers/' + name + '.jpg'
urllib.request.urlretrieve(avatar_url, image_name)
print(name+'\t'+gender+'\t'+str(follower_count))
kv = [follower_count, name]
info.append(kv)
cursor.execute("insert into zhihu_info(token, name, gender, follower_count, answer_count, articles_count, headline, avatar_url) values(%s, %s, %s ,%s, %s ,%s, %s, %s)", (token, name, gender, follower_count, answer_count, articles_count, headline, avatar_url))
conn.commit()
url = j['paging']['next']
if url:
time.sleep(2)
followers(url)


def visualize():
# 性别饼状图
sizes = []
cursor.execute('select count(*) from zhihu_info where gender="男";')
sizes.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where gender="女";')
sizes.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where gender="性别未知";')
sizes.append(cursor.fetchone()[0])
labels = 'Male:'+str(sizes[0]), 'Female:'+str(sizes[1]), 'Unknown:'+str(sizes[2])
explode = [0, 0.1, 0]
plt.title('Sex Distribution of Followers')
plt.pie(sizes, explode=explode, labels=labels, autopct='%1.2f%%', startangle=90)
plt.show()

# 是否含有简介的饼状图
sizes = []
cursor.execute('select count(*) from zhihu_info where headline!=""')
sizes.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where headline=""')
sizes.append(cursor.fetchone()[0])
labels = 'Contains introduction:'+str(sizes[0]), 'Does not include introduction:'+str(sizes[1])
explode = [0.1, 0]
plt.title('Introduction Distribution of Followers')
plt.pie(sizes, labels=labels, explode=explode, autopct='%1.2f%%', startangle=90)
plt.show()

# 关注用户的粉丝数量分布计算
data = []
cursor.execute('select COUNT(*) from zhihu_info where follower_count <=100')
data.append(cursor.fetchone()[0])
cursor.execute('select COUNT(*) from zhihu_info where follower_count>100 and follower_count <=500')
data.append(cursor.fetchone()[0])
cursor.execute('select COUNT(*) from zhihu_info where follower_count>500 and follower_count <=1000')
data.append(cursor.fetchone()[0])
cursor.execute('select COUNT(*) from zhihu_info where follower_count>1000 and follower_count<=2000')
data.append(cursor.fetchone()[0])
cursor.execute('select COUNT(*) from zhihu_info where follower_count>2000 and follower_count<=5000')
data.append(cursor.fetchone()[0])
cursor.execute('select COUNT(*) from zhihu_info where follower_count>5000 and follower_count<=10000')
data.append(cursor.fetchone()[0])
cursor.execute('select COUNT(*) from zhihu_info where follower_count>10000 and follower_count<=100000')
data.append(cursor.fetchone()[0])
cursor.execute('select COUNT(*) from zhihu_info where follower_count >100000')
data.append(cursor.fetchone()[0])
print(data)
labels = ['0-100', '10-500', '500-1k', '1k-2k', '2k-5k', '5k-1w', '1w-10w', '>10w']
plt.bar(range(len(data)), data, tick_label=labels, color='rgb')
plt.title('Follower\'s Fan Distribution')
plt.show()

# 关注用户的回答数分布图
data = []
cursor.execute('select count(*) from zhihu_info where answer_count=0')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>0 and answer_count<=5')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>5 and answer_count<=10')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>10 and answer_count<=20')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>20 and answer_count<=30')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>30 and answer_count<=40')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>40 and answer_count<=50')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>50 and answer_count<=100')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>100 and answer_count<=200')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>200 and answer_count<=500')
data.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where answer_count>500')
data.append(cursor.fetchone()[0])
print(data)
labels = ['0', '1-5', '6-10', '11-20', '21-30', '31-40', '41-50', '51-100', '101-200', '201-500', '>500']
plt.title('Number of Answers Distribution')
plt.bar(range(len(data)), data, width=0.5, tick_label=labels, color='rgb')
plt.show()

# 用户是否写文章的饼状图
sizes = []
cursor.execute('select count(*) from zhihu_info where articles_count=0')
sizes.append(cursor.fetchone()[0])
cursor.execute('select count(*) from zhihu_info where articles_count!=0')
sizes.append(cursor.fetchone()[0])
labels = 'No Article user:' + str(sizes[0]), 'Article user:' + str(sizes[1])
explode = [0, 0.1]
plt.title('Articles Distribution of Followers')
print(sizes)
plt.pie(sizes, labels=labels, explode=explode, autopct='%1.2f%%', startangle=90)
plt.show()


url = 'https://www.zhihu.com/api/v4/members/excited-vczh/followees?include=data%5B*%5D.answer_count%2Carticles_count%2Cgender%2Cfollower_count%2Cis_followed%2Cis_following%2Cbadge%5B%3F(type%3Dbest_answerer)%5D.topics&offset=20&limit=20'
followers(url)
visualize()

注意:

  • 代码中的 Mysql 的连接需要换成你自己的数据库、表、用户名和密码

  • cookie 使用你登录知乎的cookie

  • 头像本地存储需要对’/Users/rundouble/Desktop/excited-vczh_followers/‘ + name + ‘.jpg’修改成你的路径

  • 对URL中的 url_token 可以换成其他用户的url_token 也可以正常使用

  • 代码中一共两个方法,一个是 followers(url) 进行数据的爬虫,另一个是 visualize()

  • 对爬取到数据进行简单的可视化

  • 本次爬虫时间截止于:2018.3.28

本文完。

-------------本文结束感谢您的阅读-------------