bilibili多进程爬虫

一些闲话

接下来将近两周没有课,我就帮一位同学写了个bilibili爬虫爬取十万条用户数据,来完成TA的大数据分析作业。原本以为用以前的日语听力习题爬虫改一改就能用,结果……B站果然是B站啊,反爬虫的本事还是相当高的。

先上最终完成版的源代码,注释应该够详细了:

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
# 注意修改无头浏览器的地址,该装的库要装
# .csv文件请提前准备好
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
import csv
from multiprocessing import Pool
import os, time
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
import random
# 子进程,主要代码都写在这里
def scrawler_process(process_start, num, sum, pace):
print('Run task %s (%s)...' % (num, os.getpid()))
start = time.time()
# 修改请求头,反爬虫
dcap = dict(DesiredCapabilities.PHANTOMJS)
dcap["phantomjs.page.settings.userAgent"] = (
"Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.23 Mobile Safari/537.36"
)
service_args = []
# 关闭图片加载有蜜汁BUG
#service_args.append('--load-images=no') ##关闭图片加载
service_args.append('--disk-cache=yes') ##开启缓存
service_args.append('--ignore-ssl-errors=true') ##忽略https错误
phantom_path = 'phantomjs\\bin\\phantomjs.exe'
driver = webdriver.PhantomJS(executable_path=phantom_path, service_args=service_args)
#已爬取的有效数据量
counter = 0
#urlNumber
urlNumber = process_start + num
#爬取连续失败的次数
fail_num = 0
#写入的文件名
filename = 'bilibili-%d.csv' % (num)
out = open(filename, "a", newline="", encoding="utf-8")
csv_writer = csv.writer(out, dialect="excel")
while (counter < sum and urlNumber < 272500000):
# 随机选取uid
url = "https://space.bilibili.com/%d#/dynamic" % (urlNumber + random.randint(-1249, +1249)) # 要爬取的地址
driver.get(url)
print(url)
# bsObj = BeautifulSoup(driver.page_source)
# print(bsObj.prettify())
try:
#判定网页是否加载完全
element = WebDriverWait(driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "content")))
span = driver.find_element_by_id("h-name")
#获取性别的元素并处理
sexSpanClass = driver.find_element_by_id("h-gender").get_attribute("class").split(" ")
if len(sexSpanClass) == 3:
sex = sexSpanClass[2]
else:
sex = "未填写"
# 获取等级的元素并处理
level = driver.find_element_by_css_selector(
"#space-body > div.h > div.wrapper > div.h-inner > div.h-user > div > div.h-basic > div:nth-child(1) > a.h-level.m-level").get_attribute(
"lvl")
uid = urlNumber
# 获取注册时间的元素并处理
regtime = driver.find_element_by_class_name("regtime").find_element_by_class_name("text")
regtime_text = regtime.text
if (regtime_text == ''):
regtime_text = "未填写"
# 获取生日的元素并处理
birthday = driver.find_element_by_class_name("birthday").find_element_by_class_name("text")
birthday_text = birthday.text
if (birthday_text == ''):
birthday_text = "未填写"
# 获取地理位置的元素并处理
geo = driver.find_element_by_class_name("geo").find_element_by_class_name("text")
geo_text = geo.text
if (geo.text == ''):
geo_text = "未填写"
# 获取粉丝数的元素并处理
fan_num = driver.find_element_by_id("n-fs")
fan_num_text = fan_num.text
if (fan_num_text[-1] == "万"):
fan_num_text = float(fan_num_text[:-1]) * 10000
# 以span是否存在作为网页是否加载成功的依据
if (span != None):
nickname = span.text
print(nickname, sex, level, uid, regtime_text[3:].strip(), birthday_text, geo_text, fan_num_text)
row = [nickname, sex, level, uid, regtime_text[3:].strip(), birthday_text, geo_text, fan_num_text]
csv_writer.writerow(row)
# print(bsObj.find(id="h-name").get_text())
urlNumber += pace
counter += 1
fail_num = 0
driver.get("http://about:blank")
except TimeoutException:
urlNumber += pace
fail_num += 1
#如果连续失败三次,说明被反爬虫或大片的uid不存在
if(fail_num > 2):
driver.get("https://www.bilibili.com/")
print('sleep 30s')
#缓一缓歇会儿再爬
time.sleep(30)
#连续失败次数清零,重新计数
fail_num = 0
#跳过不存在的uid
urlNumber += 250000
#PHANTOMJS可能存在一种BUG,多进程爬取时网页的信息会弄串了,访问空白页可以重置
driver.get("http://about:blank")
driver.close()
end = time.time()
print('Task %s runs %0.2f seconds.' % (num, (end - start)))
# 主程序
if __name__=='__main__':
# 记录开始时间
start_time = time.time()
#从哪一条id开始爬取
crawler_start = 0
#进程数
crawler_num = 1
#每个进程要爬取的有效数据量
crawler_sum = 10000
#隔多少id爬取一次
crawler_pace = 2500
print('Parent process %s.' % os.getpid())
#进程池
p = Pool(crawler_num)
#启动进程
for i in range(crawler_num):
p.apply_async(scrawler_process, args=(crawler_start, i, crawler_sum, crawler_pace,))
print('Waiting for all subprocesses done...')
p.close()
p.join()
print('All subprocesses done.')
end_time = time.time()
print(end_time - start_time)

代码背后的故事

咋一看很简单,用的知识点都很初级。项目这东西从来都是说着容易做着难,为了这150多行代码我也折腾了三天。

初期似乎很顺利

B站的UID排列很规律——从0排到两亿多,理论上我只要遍历就行。

原本不想用无头浏览器————效率还是太低。可只利用Beautifulsouprequestsurllib什么的,即使设置文件请求头,B站给我返回的页面是“您的浏览器不支持访问个人主页,请升级浏览器”。用无头浏览器没有这个问题。原因以后可以深究。

最开始的版本是单线程的,比起久远的日语听力题爬虫就多了个WebDriverWait隐式等待页面加载完成,稍微提高一点效率。某天深夜爬取1000条数据用了1480秒,寻思着这也太慢了点,第二天就开始折腾多进程。

这个爬虫之前我也没写过多进程。俗话说得好,“人生苦短,我用python”,我看着廖雪峰Python3教程直接上手做,除了在这里传参时搞错了位置:

# 正确的做法
p.apply_async(scrawler_process, args=(crawler_start, i, crawler_sum, crawler_pace,))
# 错误的做法
p.apply_async(scrawler_process(crawler_start, i, crawler_sum, crawler_pace))

导致进程无法同时启动稍微耽搁了一会儿,没有别的问题。

这里要提一下,为什么用多进程而非多线程。引用一下廖雪峰大神的话吧————

Python解释器由于设计时有GIL全局锁,导致了多线程无法利用多核。多线程的并发在Python中就是一个美丽的梦。

我试着开4个进程爬4组数据,结果————


与B站反爬虫的初次交手

嗯哼,我被封IP了。一开始封个半分钟到三分钟不等,我干脆让爬虫歇息30秒再爬(所以源代码里还有sleep(30)的代码),结果B站把我封了一个多小时,我差点以为这辈子再也没法用宿舍的网上B站了……

让爬虫休息没用,我开始推测B站的反爬虫策略。我试着不持续访问用户主页去别的页面转悠转悠,可没有明显效果;改请求头也成效不大,看来B站就是根据IP地址访问频率来反爬虫的。

终极的对策是用IP代理池分布式爬取。可我就做一个学校的作业,不想搞得那么麻烦(其实是太菜没做过)。看来还是别爬得太狠,开一两个进程就行了。我暂时认输。

这里分享一篇分布式爬虫的文章:分布式B站爬虫任务系统
还有高素质的知乎:IP代理池相关讨论
《Python爬虫开发与项目实战》作者的博客:据说大家都能用的IP代理池


利用API?

自己搞不定,就上网搜呗。网上大量的爬虫都是一两年前的,当时B站反爬虫还没现在那么狠,甚至有对外公开的API接口。可惜B站被爬怕了。如果有条件,去试着申请API吧~我只能放弃。


效率优化

还是回到无头浏览器的老路子。我阅读了几篇相关的文章,这里罗列出来:

盘点selenium phantomJS使用的坑
我认为的重点/碰到的坑:

  1. phantomJS无头浏览器的配置(不加载图片、请求头、代理、超时返回等)
  2. phantomJS的并发问题(多线程满满的BUG,还好我用的多进程)

Phantomjs性能优化
我认为的重点/碰到的坑:

  1. 关闭图片加载功能似乎有问题,会导致浏览器进程莫名罢工
  2. 全篇都是重点

【phantomjs系列】Selenium+Phantomjs爬过的那些坑
Selenium+PhantomJS的爬虫那些事儿
我认为的重点/碰到的坑:

  1. Phantomjs连续访问网页时的状态污染问题么……保险起见,我按文中所述,每次访问网页后加上driver.get("about:blank")
  2. 这两篇文章说的是同一个项目的同一个问题,由该项目的不同技术人员写的

知乎-selenium 怎样设置请求头?
我认为的重点/碰到的坑:

  1. 全篇都是重点
  2. 注意Chrome也有无头版本的。鉴于phantomjs已经无人维护,使用Chrome headless才是正道

文件读写、字符串函数、字符集、随机函数、无效UID

爬取下来的原始数据不适合导入数据库处理。我用字符串函数好好休整了一番,其间艰辛自不必说,看源码吧。

爬取的数据总得找个地方存。应同学的要求,我选择.csv格式存储。也不难写:

Python读写CSV文件操作

千辛万苦走到这一步,可以很愉快地爬数据了是不是?Naive!
字符集!字符集!字符集!
Python默认的编码格式与操作系统相同————中文系统应该是GBK。而我的爬虫在爬到某个用户昵称里有阿拉伯语的奇葩时,很愉快地报错挂掉了…………

还好问题发现得早,这样改一改就行:

out = open(filename, "a", newline="", encoding="utf-8")

另一个问题就发现得晚了————爬取的用户生日集中在一月份————原因是我的爬虫每隔2500个uid爬取一次数据,导致爬取的用户生日都撞车。这个问题发现时已经爬了五千组数据了,这个问题嘛,Emmmmmmm……于是要给urlNumber加上随机函数。

另外,可能是B站回滚过数据库,有大片连续的uid是无效的。所以我设置了连续失败三次后urlNumber增加量为正常pace的十倍,以此来跳过无效的uid

这里顺便附上在linux系统下遇到乱码问题的解决方案。

第二篇文章中,这个命令很好使:

iconv -f gb2312 -t utf-8 test.txt> testutf8.tzt

命令格式一目了然,就不多做说明。

终于可以愉快地爬数据了~~~


结尾的一些闲话

我有一个学姐半开玩笑地说过一句话:

程序猿其实应该属于手工业,才不是什么高新技术产业。

确实,码代码是需要熟练的,和做手工活一样。这个爬虫从技术原理上讲没有很高的难度(也就比入门级高一点而已),真写起代码来问题丛生。想成为合格的程序猿?“无他,唯手熟耳。”

幸甚至哉,歌以咏志。