Scrapy 之断点续爬

2,890 阅读11分钟
原文链接: www.jianshu.com

Scrapy第五篇:断点续爬 | 存入MySQL

五一前后疯癫玩了一周(纯玩耍真的),然后又应付本专业各种作业、PPT?
本来想先解决IP这一块,结果被坑惨了,辗转两天先跳过,心累


40918944


好久不学习,惭愧,不说了我们回归正题。

不得不说scrapy真的是一个强大的框架,配上轻灵简洁的mongodb,只需极少代码便可应付一个简单爬虫。但如果几十万几百万的数据量,需要考虑的因素就很多了,速度、存储、代理Ip应对反爬等等

还有一个重要的,比如某一天你好不容易写完代码,心想“这下好了,接下来全部丢给机器我真机智哈哈哈”,于是睡觉/玩耍/撩妹美滋滋,回来一看,不巧,断网/IP被封/奇异bug,这时你的内心是这样的:


所以你需要——断点续爬
好吧貌似又胡扯了(其实是喜欢这个表情,强行用上哈哈)

本次目标:
爬取轮子哥54万关注用户信息,存入MySQL,实现断点续爬

一、爬虫思路

关于这个话题网上的教程也非常多,一般是设法获取_xsrf参数然后模拟登陆,再进行抓取。
比如下面这一篇写得真的很不错:爬虫(3)--- 一起来爬知乎,作者是简书的whenif

爬取关注用户其实更简单,可以直接调用API
一般用开发者工具抓包,只能获得以下字段


本篇将获得以下字段:



大概30来项,调用URL:

www.zhihu.com/api/v4/memb…

这么多字段哪里来?好多天前了,抓包过程中无意发现的,easy(相信有盆友早就知道了)

其实这只是Api其中一部分字段,全部字段比这还多,为了防止查水表还是不贴上了,想要可以私信。


二、Spiders逻辑

调用Api非常简单,但是想防止被ban,要做好伪装。random.choice设置动态U-A。原以为还需要到代理Ip,没想到可以应付得过去。还是要注意爬虫的友好性,下载延迟3s。就不赘述。

需要注意的是header尽量模拟浏览器,'authorization'参数一定要带上,如果不带上无法返回数据,同时这个参数是有有效期限的,失效返回401,需要重新抓取。

DEFAULT_REQUEST_HEADERS = {
    'accept':'application/json, text/plain, */*',
    'Accept-Encoding':'gzip, deflate, sdch',
    'Accept-Language':'zh-CN,zh;q=0.8',
    'authorization':'xxx(自己抓)',
    'Connection':'keep-alive',
    'Host':'www.zhihu.com',
    'Referer':'https://www.zhihu.com/people/excited-vczh/followers',
    'x-udid':'AIDAV7HBLwqPTulyfqA9p0CFbRlOL10cidE=',
    'User-Agent':random.choice(USER_AGENTS)}

接着就是提取数据的部分,打开上面的URL,返回一堆Json格式数据

自定义一个Too类清洗headline(签名)、description(个人简介),为了方便调用放在settings中;还有locations(居住地址)和business(行业),不是所有用户都填写的,需要判断;还有educations(教育经历)和employments(职业经历),有各种可能,以前者来说:
1、有'school'、有'major'
2、有'school'、无'major'
2、无'school'、有'major'
4、无'school'、无'major'
同时还可能有若干项,比如某某用户教育经历:
1、xx小学
2、xx初中
3、xx高中
4、xx大学

in dict.keys() 方法判断dict中某一属性是否存在
一堆if/else,一点也不优雅,无可奈何

 if len(i['educations'])== 0:
    item['educations']=''
else:
    content=[]
    for n in i['educations']:
        S='school' in n.keys()
        M='major' in n.keys()
        if S:
            if M:
               self.L=n['school']['name']+'/'+n['major']['name']
            else:
               self.L=n['school']['name']
        else:
            self.L=n['major']['name']
        content.append(self.L)
    item['educations']=''
    for l in content:
        item['educations']+=l+'  '

employments同理,L等设为全局变量。
最后获得这样,美丽的数据,不容易



三、存储入MySQL

1、首先下载安装MySQL及服务
如果是5.7以后的windows系统,推荐以下教程,很详细:
windows安装MySQL数据库(非安装版),设置编码为utf8

我是之前某天安装的,忘记密码,上网搜到一个可以跳过验证的教程,好不容易通过。
第二天电脑重启,无法连接了,搜了网上各种方法无果。糊里糊涂又安装了个5.6.36版本,还是不行。最后全部卸载并强力删除,重新又安装了一个5.7版本的。为此注册了个oracle账户,弄到一半发现原来已经有账户了,重新邮箱验证找回密码,登录,下载,安装,终于...

不心累了,心痛。。


你看,各种坑,可能是因为windows情况许多情况必需用管理员命令才能运行。
迫切地想换linux系统!!!

2、可视化工具
MySQL可视化工具很多,例如:20个最佳MySQL GUI的可视化管理工具
也可参考某个社区网友推荐: 最好用的mysql可视化工具是什么? - 开源中国社区

我试了好几个,觉得MySQL-Front 简洁易上手,SQL-yog功能超级强大。
最喜欢还是Navicat,真的很好用,强力推荐哈哈。

比较不巧的是,想要长久服务得购买,官方正版只有14天试用期,需要验证码。

傻乎乎去找验证码,结果发现网上的都是不能用的,套路啊。最后还是找到了一个中文版navicat,用山寨验证码,验证山寨navicat,成功。虽然比较简陋,也和正式版一样好用,小小确幸?

不熟悉MySQL语法也没关系,利用Navicat可快速进行一些设置:
引擎




字段
name等文本型




gender等数字型





主键


看到这把钥匙了么?
主键设置为detailURL(用户主页地址),每一个用户唯一,机智如我哈哈

可以看到,字符集尽可能选择了utf8( utf8而不是utf-8),为什么呢?
MySQL默认使用字符集latin1,不支持中文。如果不设置成utf8,会出现下面这样:




3、MySQL基础知识
mysql查询:【图文】mysql查询数据相关操作_百度文库
Python中操作mysql:python 操作MySQL数据库 | 菜鸟教程

4、自定义模块存储
这里参考了博文: 小白进阶之Scrapy第一篇 | 静觅 - Part 3
使用Mysql-connector操作mysql(当然也可以用上面的MySQLdb方法)
但是觉得里面有些部分有些冗赘,而且我们已经设置了主键,改成下面这样

<Mysql.py>
在这之前需要在settings中设置各种MySQL配置参数,这里不赘述

# -*-coding:utf-8 -*-
#导入settings中各种参数
from ZhiHu import settings
import mysql.connector

# 使用connector连接到数据库
db=mysql.connector.Connect(user=settings.MYSQL_USER, host=settings.MYSQL_HOST, port=settings.MYSQL_PORT,password=settings.MYSQL_PASSWORD, database=settings.MYSQL_DB_NAME)
# 初始化操作游标,buffer指的是使用客户端的缓冲区,减少本机服务器的压力
cursor=db.cursor(buffered=True)

print u'成功连接数据库!'

class MySql(object):
    @classmethod
    def insert_db(cls,item):
        '''数据插入数据库,用到装饰器'''
        sql="INSERT INTO zhihu(name,headline,description,detailURL,gender,user_type,is_active,locations,business,educations,employments,following_count,follower_count,mutual_followees_count,voteup_count,thanked_count,favorited_count,logs_count,following_question_count,following_topic_count,following_favlists_count,following_columns_count,question_count,answer_count,articles_count,pins_count,participated_live_count,hosted_live_count) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)"

        values=(item['name'], item['headline'], item['description'], item['detailURL'], item['gender'],
            item['user_type'], item['is_active'], item['locations'], item['business'],
            item['educations'], item['employments'], item['following_count'], item['follower_count'],
            item['mutual_followees_count'], item['voteup_count'], item['thanked_count'], item['favorited_count'],
            item['logs_count'], item['following_question_count'], item['following_topic_count'],
            item['following_favlists_count'], item['following_columns_count'], item['articles_count'],
            item['question_count'], item['answer_count'], item['pins_count'], item['participated_live_count'],
            item['hosted_live_count'])

        cursor.execute(sql,values)
        db.commit()

将dict改成set形式来装,减少累赘更简洁

<pipelines.py>

# -*- coding: utf-8 -*-
from ZhiHu.items import ZhihuItem
from Mysql import MySql
import mysql.connector

class ZhihuPipeline(object):
    def process_item(self,item,spider):
        if isinstance(item,ZhihuItem):
            try:
                MySql.insert_db(item)
                print u'成功!'
            except mysql.connector.IntegrityError as e:
                print 'mysql.connector.IntegrityError'
                print u'主键重复,不存入'

这样一来利用主键,实现了有效去重


四、断点续爬

扯来扯去,终于到重点啦@_@
其实也是知乎上的问题:python爬虫如何断点继续抓取? - 知乎
非常赞同路人甲大神的建议,断点续爬核心:**记录下最后的状态**。

最初的想法是,手动记录到笔记本上。可是每次这样未免太繁琐了,又容易丢,不如写一个函数(中间件),连同抓取的字段一并记录到数据库,需要重新抓取时从数据库调用

后面发现,想法与这个答案不谋而和:



分析网页最后几页发现其是不变的,只是页码增加,大概知道网站堆积数据的方式,那就将计就计?从最后一页倒过来抓取

但是我们要注意到,轮子哥的关注量不断增加,两次抓取之间数据是有偏移的!!!

这么说,比如这一次某一用户的位置在i=10这个位置,下一次爬虫之前新增关注3。下一次爬虫时,它会漂移到i=13这个位置。

需要考虑的有点多,几个字段

All_Num —— 目标抓取量(zhihu.py中手动输入)
Save_Num—— 已经抓取量(items字段 )
Last_Num —— 上一次爬虫,轮子哥关注量(items字段)
Now_Num —— 本次爬虫,轮子哥关注量(爬虫开始前,requests方法检测,检测第一页即offset=0时)
Real_Num —— 记录URL中的i(items字段,数据随着用户关注有偏移,不规则)

Save_Num,Last_Num,Real_Num都需要调用/保存入数据库。前面我们已经设置好数据库连接、游标等,直接在Mysql.py再自定义一个NumberCheck类获取,最后Zhihu.py中导入

MySQL索引最后一条数据"SELECT 字段名 FROM 表名 ORDER BY 字段名 DESC LIMIT 1;",DESC表示倒序)

class NumberCheck(object):
    @classmethod
    def find_db_real(cls):
        '''用于每次断点爬虫前,检查数据库中最新插入的一条数据,
        返回最后一条数据的序号'''
        sql="SELECT Real_Num FROM zhihu ORDER BY Save_Num DESC LIMIT 1;"
        cursor.execute(sql)
        result=cursor.fetchall()  #fetchall返回所有数据列表
        for row in result:
            db_num=row[0]
            return db_num

    @classmethod
    def find_last(cls):
        '''用于每次断点爬虫前,检查数据库中最新插入的一条数据,
        返回上次关注量'''
        sql = "SELECT Last_Num FROM zhihu ORDER BY Save_Num DESC LIMIT 1;"
        cursor.execute(sql)
        result = cursor.fetchall()
        for row in result:
            last_num = row[0]
            return last_num

    @classmethod
    def find_save(cls):
        '''用于每次断点爬虫前,检查数据库中最新插入的一条数据,
        返回总共抓取量'''
        sql = "SELECT Save_Num FROM zhihu ORDER BY Save_Num  DESC LIMIT 1;"
        cursor.execute(sql)
        result = cursor.fetchall()
        for row in result:
            save_num = row[0]
            return save_num

在Zhihu.py中初始化各种参数,第一次抓取和之后是不一样的,详情看注释

# -*- coding:utf-8 -*-
import scrapy
from scrapy.http import Request
from ZhiHu.items import ZhihuItem
from ZhiHu.MysqlPipelines.Mysql import NumberCheck
from scrapy.conf import settings
from ZhiHu.settings import Tool
import requests
import json

class Myspider(scrapy.Spider):
    '''初始化各种参数'''
    name='ZhiHu'
    allowed_domains=['zhihu.com']
    L = ''
    K = ''

    All_Num=546049 # 目标抓取量(要保证是该时间最新关注量)
    Save_Num=NumberCheck.find_save() # 已经抓取量
    DB_Num=NumberCheck.find_db_real()  # 上次爬虫,数据库的最后一条数据DB_Num
    Last_Num=NumberCheck.find_last()  # 获得上一次爬虫,轮子哥关注量

    #用requests检测最新关注量
    url='xxxxxx(和前面一样)&offset=0'
    response=requests.get(url, headers=settings['DEFAULT_REQUEST_HEADERS'])
    parse=json.loads(response.text)
    try:
        # 获得最新关注者数目Now_Num,注意检验token是否过期
        Now_Num=parse['paging']['totals']
        # 因为关注者不时更新,需计算出真实Real_Num,分两种情况讨论
        # 第一次DB_Num和Last_Num为None,第二次之后不为None
        if DB_Num is not None:
            if Last_Num is not None:
                Real_Num = DB_Num+(Now_Num-Last_Num)-1
                Save_Num = Save_Num
        else:
            Real_Num=All_Num
            Save_Num=0
        print u'目标爬取:', All_Num
        print u'已经抓取:', Save_Num
        print u''
        print u'目前关注:',Now_Num
        print u'上次关注:',Last_Num
    except KeyError:
        print u'\n'
        print u'Authorization过期,请停止程序,重新抓取并在settings中更新'

来看下结果:


分别是关注者为546361和546059的状态(相差300多,大家也可以猜想一下过了多长时间)

找到网页进行比对


发现了没有,顺序一致,连上了

运行过程大概是下面这样(第一次和之后有些不一样)




结果都不一定,有时立马能续连,有的连续返回几个mysql.connector.ItergrityError(之前已经存过)才连上。不过一般来说,数据越是中间断开,变动也会稍大;断点时间差的越大,变动也可能更大。

还好的是,我们用MySQL设置主键来实现去重,可以减少变动造成的影响:同时终于可以忽略scrapy本身去重的不完美。(注意self.Real_Num+=1设置的位置)

此间我们没有考虑爬虫过程中新增用户关注造成的影响,没有考虑取关,没有考虑其它变化。
一个简陋的“伪动态断点续爬”,大概就是这样。


五、总结

这篇文写的好累呀,还是总结下比较好
1、爬虫思路:你所没有发现的API,原来可以抓许多字段
2、Spiders逻辑:主要是数据清洗,值得注意的in dict.keys()方法判断属性是否存在
3、MySQL存储:
1)下载安装
2)可视化工具(Navicat)
3)基础知识
4)自定义模块进行存储:设置主键去重,装饰器,python中操作MySQL两种方式—— MySQLdb和mysql connector

4、断点续爬:核心是记录最后的状态,本篇中采用存入数据库的方式,需要设置各种字段。对于动态更新的项目,断点续爬会有变动,有些麻烦。

完整代码:github地址

对了想获得山寨版,哦不,经济适用版Navicat下载安装包的童鞋可以关注我的微信公众号:


然后回复:Navicat,可获取百度云分享链接


么么哒,本篇就是这样啦~