在一个月前,我写了一篇scrapy杂记记录了爬取lol.qq.com获取英雄联盟数据及英雄皮肤原画的过程。第一次使用scrapy后,了解了大致的爬取流程,但在细节上(例如防ban策略,奇怪数据处理)没太在意,处于编码第一阶段(能跑就行)。
中间学了半个月的Qt5和pygame,(没学出个什么样子,了解了大致概念,翻指南能上手了),之后,看到github中早期fork了一个库,airingursb先生(大概)写的bilibili-user,深有所悟,在此先感谢他的源码及他的开源精神。
但最近一段时间,B站的网站结构有了些许的变化,我就尝试着用scrapy重写这个功能,以只修改item的方式保证这个爬虫的生命(理论上,更换item对应的xpath位置就可以应对页面元素更改)。并在此基础上增加一些防ban策略,深化对爬虫的编写能力,以及应对可能过大的数据处理任务(单纯的构造url,截止5月3日,b站已经有了323000449账号详情界面,之前的lol爬虫上千条数据就把路由器撑爆了,这次可能要应付3亿条数据)。完整代码可见bilibili-user-scrapy
1. uid 用户id,int
2. mid 用户id,str
3. name 用户姓名,str
4. sex 用户性别,str
5. regtime 用户注册时间,str
6. birthday 用户生日,str
7. place 用户住址,str
8. fans 用户粉丝数,int
9. attention 用户关注数,int
10. level 用户等级,int
1. 数据库设计及数据插入
2. js页面数据的获取
3. 特殊数据的处理
4. 防ban策略
deepin下安装docker
其他系统安装docker
安装方法
# scrapy库
conda install Scrapy
# scrapy_splash库
conda install scrapy_splash
# pymysql库(conda无法安装,迷)
pip3 install pymysql
MySQL安装
只需要一个爬虫就ok了。
打开items.py,添加代码:
import scrapy
class BilibiliUserScrapyItem(scrapy.Item):
# define the fields for your item here like:
# name = scrapy.Field()
# coins = scrapy.Field()
# friend = scrapy.Field()
# exp = scrapy.Field()
uid = scrapy.Field() # int id
mid = scrapy.Field() # str id
name = scrapy.Field()
sex = scrapy.Field()
regtime = scrapy.Field()
birthday = scrapy.Field()
place = scrapy.Field()
fans = scrapy.Field()
attention = scrapy.Field()
level = scrapy.Field()
注释部分的内容,由于隐私不可见,暂时无法获取。
这里不在赘述如果有mysql建表,更多mysql可见MySQL教程。
这里只需要知道我的数据库配置即可。
MYSQL_HOST = '127.0.0.1'
MYSQL_DBNAME = 'bilibili' #数据库名字,请修改
MYSQL_USER = 'light' #数据库账号,请修改
MYSQL_PASSWD = '123456' #数据库密码,请修改
MYSQL_PORT = 3306
tablename:bilibili_user_info
pipelines是对spider爬取到的item进行处理的过程,在这个爬虫中,我们需要对获得的数据进行转码并储存在mysql数据库中。记得将BilibiliUserScrapyPipeline添加到配置文件settings.py中。
import pymysql
from scrapy import log
from bilibili_user_scrapy import settings
from bilibili_user_scrapy.items import BilibiliUserScrapyItem
class BilibiliUserScrapyPipeline(object):
def __init__(self):
self.connect = pymysql.connect(
host=settings.MYSQL_HOST,
db=settings.MYSQL_DBNAME,
user=settings.MYSQL_USER,
passwd=settings.MYSQL_PASSWD,
charset='utf8',
use_unicode=True)
self.cursor = self.connect.cursor()
def process_item(self, item, spider):
try:
self.cursor.execute("""select * from bilibili_user_info where uid=%s""", item['uid'])
ret = self.cursor.fetchone()
if ret:
self.cursor.execute(
"""update bilibili_user_info set
mid=%s,name=%s,sex=%s,
regtime=%s,birthday=%s,place=%s,
fans=%s,attention=%s,level=%s
where uid=%s""",
(item["mid"],
item["name"],
item["sex"],
item["regtime"],
item["birthday"],
item["place"],
item["fans"],
item["attention"],
item["level"],
item["uid"]))
else:
self.cursor.execute(
"""insert into bilibili_user_info(uid,mid,name,sex,regtime,birthday,
place,fans,attention,level)
values(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(item['uid'],
item["mid"],
item["name"],
item["sex"],
item["regtime"],
item["birthday"],
item["place"],
item["fans"],
item["attention"],
item["level"]))
self.connect.commit()
except Exception as error:
log.msg(error)
print("error",error)
return item
简单粗暴,先连接数据库,然后查询数据库,若存在则更新,不存在则插入。
# -*-coding:utf-8 -*-
import pymysql
import re
import sys
import random
import time
from imp import reload
from scrapy.http import Request
from scrapy.spiders import Spider
from scrapy.selector import Selector
from scrapy_splash import SplashRequest
from bilibili_user_scrapy.items import BilibiliUserScrapyItem
reload(sys)
# 获取随机user_agent
def LoadUserAgents(uafile):
"""
uafile : string
path to text file of user agents, one per line
"""
uas = []
with open(uafile, 'rb') as uaf:
for ua in uaf.readlines():
if ua:
uas.append(ua.strip()[1:-1-1])
# random的序列随机混合方法
random.shuffle(uas)
return uas
ua_list = LoadUserAgents("user_agents.txt")
# 默认header
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36',
'X-Requested-With': 'XMLHttpRequest',
'Referer': 'http://space.bilibili.com/45388',
'Origin': 'http://space.bilibili.com',
'Host': 'space.bilibili.com',
'AlexaToolbar-ALX_NS_PH': 'AlexaToolbar/alx-4.0',
'Accept-Language': 'zh-CN,zh;q=0.8,en;q=0.6,ja;q=0.4',
'Accept': 'application/json, text/javascript, */*; q=0.01',
}
# 主爬虫类
class BILIBILIUserSpider(Spider):
name = "bilibili_user_scrapy"
start_urls = []
# 截止2018/5/2日,B站注册账号数量
start = 1
end = 323000449
# 构造url,根据机能分批爬取,未进行分布式爬虫
for i in range(2000, 100000):
url = "https://space.bilibili.com/"+str(i)+"/#/"
start_urls.append(url)
def start_requests(self):
for url in self.start_urls:
time.sleep(1)
# 随机headers
headers = {'User-Agent':random.choice(ua_list),
'Referer':'http://space.bilibili.com/'+str(random.randint(9000,10000))+'/'}
yield SplashRequest(url=url, callback=self.parse, args={'wait':0.5},
endpoint='render.html',splash_headers=headers,
)
def parse(self, response):
# 爬虫item类
item = BilibiliUserScrapyItem()
#一些常规的元素抓取
attention = response.xpath("//*[@id=\"n-gz\"]/text()").extract_first()
fans = response.xpath("//*[@id=\"n-fs\"]/text()").extract_first()
level = response.xpath("//*[@id=\"app\"]/div[1]/div[1]/div[2]/div[2]/div/div[2]/div[1]/a[1]/@lvl").extract_first()
# 由于未知的原因,部分页面无法正确加载某些元素
# 当元素为None时,将其设置为‘null’
# 但uid特殊,必须存在,所以从response.url中截取
uid = response.url[27:-3]
# uid = response.xpath("//*[@id=\"page-index\"]/div[2]/div[6]/div[2]/div/div/div[1]/div[1]/span[2]/text()").extract_first()
sex = response.xpath("//*[@id=\"h-gender\"]/@class").extract_first()
# 小数值直接int
item['attention'] = int(attention)
item['level'] = int(level)
item['birthday'] = response.xpath("//*[@id=\"page-index\"]/div[2]/div[6]/div[2]/div/div/div[2]/div[1]/span[2]/text()").extract_first()
item['name'] = response.xpath("//*[@id=\"h-name\"]/text()").extract_first().strip()
item['place'] = response.xpath("//*[@id=\"page-index\"]/div[2]/div[6]/div[2]/div/div/div[2]/div[2]/a/text()").extract_first()
item['regtime'] = response.xpath("//*[@id=\"page-index\"]/div[2]/div[6]/div[2]/div/div/div[1]/div[2]/span[2]/text()").extract_first()
item['uid'] = int(uid)
item['mid'] = uid
# 对性别进行处理
if len(sex.split(" ")) == 3:
item['sex'] = sex.split(" ")[2]
else:
item['sex'] = 'null'
# 对地址进行处理
if item['place'] is None:
item['place'] = "null"
# 对fans进行处理
if "万" in fans:
item['fans'] = int(float(fans[:-3])*10000)
else:
item['fans'] = int(fans)
# 对生日进行处理
if item['birthday'] is None:
item['birthday'] = "null"
else:
item['birthday'] = item['birthday'].strip()
# 对注册时间进行处理
if item['regtime'] is None:
item['regtime'] = "null"
else:
item['regtime'] = item['regtime'].strip()
# 这些项暂时无法直接从界面获取
#item['coins'] = response.xpath("/html/body/div[1]/div/div[2]/div[3]/ul/li[1]/div/div[1]/div[2]/div[1]/a/span[2]/text()").extract_first()
#item['friend'] = item["fans"]
#item['exp'] = response.xpath("/html/body/div[1]/div/div[2]/div[3]/ul/li[1]/div/div[1]/div[3]/a/div/div[3]/div/text()").extract_first()
yield item
这个爬虫的设计思路如下:
1、设置user_agents(放在第五节描述)
2、设置proxy(放在第五节描述)
3、构造url
4、获取数据
5、对特殊数据进行处理
6、返回到pipeline,再插入到数据库中
# ip代理池
DOWNLOADER_MIDDLEWARES = {
'bilibili_user_scrapy.middlewares.ProxyMiddleware': 543,
}
ITEM_PIPELINES = {
'bilibili_user_scrapy.pipelines.BilibiliUserScrapyPipeline': 300,
}
# 配置mysql
MYSQL_HOST = '127.0.0.1'
MYSQL_DBNAME = 'bilibili' #数据库名字,请修改
MYSQL_USER = 'light' #数据库账号,请修改
MYSQL_PASSWD = '123456' #数据库密码,请修改
MYSQL_PORT = 3306 #数据库端口
# splash配置
SPLASH_URL = 'http://172.17.0.2:8050/' # splash在docker下的url
# 下载中间件,
DOWNLOADER_MIDDLEWARES = {
'scrapy_splash.SplashCookiesMiddleware': 723,
'scrapy_splash.SplashMiddleware': 725,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}
# 爬虫中间件
SPIDER_MIDDLEWARES = {
'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,
}
DUPEFILTER_CLASS = 'scrapy_splash.SplashAwareDupeFilter' # 去重过滤器(必须)
HTTPCACHE_STORAGE = 'scrapy_splash.SplashAwareFSCacheStorage' # 使用http缓存
虽然scrapy自带多线程异步处理,但是在代码中添加睡眠方法可能会有效。
#在spider文件中添加
import time
time.sleep(2)
ua是一个网站识别用户使用终端的手段,scrapy的默认ua就是scrapy,一般网站可以直接禁止scrapy的header进行访问,在这个爬虫中,我们先构造一个默认header头,然后从ua文件中随机获得新的ua,和原先的header结合,形成新的header进行防ban访问。
这个referer是header的一个属性,它意味着访问来源是什么,但这只是个辅助,并不确定是否真实(可能由于网络重定向或者其他原因,导致referer不准确),但我们可以利用改变referer的值以使得后端服务器觉得是不同的用户在访问。利用random方法构造不同的referer。
这个方法可能是最有效的防ban策略,但却不容易实现。首先免费的代理不多,而且质量良莠不济,过度使用代理可能会无法正常访问(你能找到的代理早被人玩过多少次了……)。如果数据量小的话就不使用代理,前面三项做好就没什么问题,数据量大的话可以考虑购买代理(商业爬虫应该是有收费代理用的吧……)。
在scrapy中使用代理不麻烦,在middlewares.py中添加一个代理类,再将这个类添加到settings.py中就可以了。
middlewares.py文件中:
# ip代理
class ProxyMiddleware(object):
proxies = {
'http':'http://140.240.81.16:8888',
'http':'http://185.107.80.44:3128',
'http':'http://203.198.193.3:808',
'http':'http://125.88.74.122:85',
'http':'http://125.88.74.122:84',
'http':'http://125.88.74.122:82',
'http':'http://125.88.74.122:83',
'http':'http://125.88.74.122:81',
'http':'http://123.57.184.70:8081'
}
def process_request(self, request, spider):
request.meta['proxy'] = random.choice(proxies)
settings.py文件中:
DOWNLOADER_MIDDLEWARES = {
'bilibili_user_scrapy.middlewares.ProxyMiddleware': 543,
}
1、实际开发时间两天,但是commit有七天,这是为什么呢?因为刚开头遇到了一个“非常低级”的错误,写代码时迷迷糊糊的,直接根据问题报告去查资料,找了很多,不得其解,觉得这可能是框架的bug,只有看源码才能解决了,然后就去干其他事了。五天后,我读了《代码整洁之道:程序员的自我修养》后重新审视这个问题,发现其实是在spider文件中错误的定义了item类型,直接定义为了默认列表类,而不是自己设置的item类。事后想到这个错误都觉得难堪,反思一下:发困的时候不要写东西,心里有事的时候要先调整好在编码。
2、在处理特殊数据的时候有些随意了,再加上未知bug,造成最后获得的数据真实有效的可能只有一半。按道理说,用户详情页面的元素应该都是一致的,但就是出现了无法获取的情况,单纯的以https://space.bilibili.com/1/#/ 和 https://space.bilibili.com/2/#/ 为例,粉丝数量的元素在xpath上位置一样,但就是无法获得正确数据,返回None。怀疑可能是splash配置的问题(毕竟这些元素都是js载入的)。
3、虽然获取数据量少,但每次获取都是进行一次http连接,所以还是没能力跑3亿条数据,这需要太多的时间,如果可以的话,可以尝试分布式爬虫。
4、下一步就是用numpy等库对获得的数十万条数据进行处理。