scrapy with bilibili

scrapy with bilibili

一、前言

在一个月前,我写了一篇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、目标网站:账户详情页

2、爬取内容:

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

3、技术:scrapy,splash,docker,mysql

4、难点

1. 数据库设计及数据插入
2. js页面数据的获取
3. 特殊数据的处理
4. 防ban策略

三、环境搭建

1、 开发语言:python v3.6.5

2、开发语言环境:anaconda v1.6.9 (非必须,但这是一个好习惯)

3、docker安装

deepin下安装docker 其他系统安装docker

4、splash

安装方法

5、一些第三方库:

# scrapy库
conda install Scrapy
# scrapy_splash库
conda install scrapy_splash
# pymysql库(conda无法安装,迷)
pip3 install pymysql

6、mysql

MySQL安装

四、爬虫设计

只需要一个爬虫就ok了。

1、定义item

打开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()    

注释部分的内容,由于隐私不可见,暂时无法获取。

2、设计mysql数据库及表

这里不在赘述如果有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

3、编写pipeline

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

简单粗暴,先连接数据库,然后查询数据库,若存在则更新,不存在则插入。

4、编写spider

# -*-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,再插入到数据库中

5、setting

# 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缓存 

五、反爬虫策略

1、设置睡眠

虽然scrapy自带多线程异步处理,但是在代码中添加睡眠方法可能会有效。 #在spider文件中添加 import time time.sleep(2)

2、设置user_agent

ua是一个网站识别用户使用终端的手段,scrapy的默认ua就是scrapy,一般网站可以直接禁止scrapy的header进行访问,在这个爬虫中,我们先构造一个默认header头,然后从ua文件中随机获得新的ua,和原先的header结合,形成新的header进行防ban访问。

3、设置referer

这个referer是header的一个属性,它意味着访问来源是什么,但这只是个辅助,并不确定是否真实(可能由于网络重定向或者其他原因,导致referer不准确),但我们可以利用改变referer的值以使得后端服务器觉得是不同的用户在访问。利用random方法构造不同的referer。

4、设置代理

这个方法可能是最有效的防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等库对获得的数十万条数据进行处理。


发表评论

评论列表,共 0 条评论

    暂无评论