爬虫笔记(11)性能问题

1.

过年也没啥事干,继续捣鼓爬虫。开始是准备爬豆瓣电影的,豆瓣存在一些反爬机制,爬一会就爬不动了。当然后面是突破了这个限制,使用随机bid,设置cookie。据说会出现验证码,我爬了几万部电影也没有出现这个问题。初期的想法是使用代理ip,网络上的免费代理ip大都不靠谱,懒得捣鼓。
在豆瓣电影这个爬虫中,我其实是使用两个步骤来执行。第一部分是按照年标签查找电影,从1900到2017年,将每个电影链接存入到mongodb中,然后进行去重(手动)。

class DoubanMovies(scrapy.Spider):
    name = "douban"
    prefix = 'https://movie.douban.com/tag/'
    start_urls = []
    for i in range(1900,2018):
        start_urls.append(prefix+str(i))

    def parse(self,response):
        names = response.xpath('//div[@class="pl2"]/a/text()').extract()
        names = [name.strip('\n/ ') for name in names]
        names = [name for name in names if len(name)>0] #去掉空名字
        movie_urls = response.xpath('//div[@class="pl2"]/a/@href').extract()
        hrefs = response.xpath('//div[@class="paginator"]/a/@href').extract()#获取分页链接
        for href in hrefs:
           yield scrapy.Request(href, callback=self.parse)
        for i in range(len(names)):
            yield {'name':names[i],'url':movie_urls[i]}

关于mongodb去重的问题,我使用的临时表。主要是我对mongodb确实不熟悉,而且我对JavaScript这样的语法着实不感冒。下面的代码很简单,每个电影链接就是https://movie.douban.com/subject/26685451/ ,我这里特地把这中间的数字提取出来,然后进行对比,这个肯定是唯一的。distinct会把获取的数据重复的进行合并,这在sql中也有这功能。

nums = movies.distinct("number")#我把链接中的数字提取了出来
m = db.movies1
for num in mums:
  movie = movies.find_one({"number":num})
  m.insert_one(movie)
moives.drop()#删除原来的数据表
m.rename('movie')#把新表命名

还有个问题就是针对douban的时间限制,需要使用DOWNLOAD_DELAY设置时间间隔,当然我使用bid突破了这个限制。
下面是第二个爬虫的代码,这个代码就是访问每个电影页面提取相关数据,然后存到mongodb中。数据提取没什么难度,为了快速判断xpath有效,最好开一个scrapy shell进行测试。

class DoubanMovies(scrapy.Spider):
    name = "doubansubject"
    def start_requests(self):
        MONGO_URI = 'mongodb://localhost/'
        client = MongoClient(MONGO_URI)
        db = client.douban
        movie = db.movie
        cursor = movie.find({})
        urls = [c['url'] for c in cursor]
        for url in urls:
            bid = "".join(random.sample(string.ascii_letters + string.digits, 11))
            yield scrapy.Request(url,callback=self.parse,cookies={"bid":bid})
def parse(self,response):

    title = response.xpath('//span[@property="v:itemreviewed"]/text()').extract_first()
    year = response.xpath('//span[@class="year"]/text()').extract_first()#(2016)
    pattern_y = re.compile(r'[0-9]+')
    year = pattern_y.findall(year)
    if len(year)>0:
        year = year[0]
    else:
        year = ""

    directors = response.xpath('//a[@rel="v:directedBy"]/text()').extract()#导演?有没有可能有多个导演呢
    '''
    评分人数
    '''
    votes= response.xpath('//span[@property="v:votes"]/text()').extract_first()#评分人数
    '''
    分数
    '''
    score = response.xpath('//strong[@property="v:average"]/text()').extract_first()#抓取分数
    #编剧不好找等会弄
    '''
    演员
    '''
    actors = response.xpath('//a[@rel="v:starring"]/text()').extract()#演员
    genres = response.xpath('//span[@property="v:genre"]/text()').extract()#电影类型
    html = response.body.decode('utf-8')
    pattern_zp = re.compile(r'<span class="pl">制片国家/地区:</span>(.*)<br/>')
    nations = pattern_zp.findall(html)
    if len(nations)>0 :
        nations = nations[0]
        nations = nations.split('/')
        nations = [n.strip() for n in nations]
    '''
    多个国家之间以/分开,前后可能出现空格也要删除
    '''
    pattern_bj = re.compile(r"<span ><span class='pl'>编剧</span>: <span class='attrs'>(.*)</span></span><br/>")
    bj_as = pattern_bj.findall(html)
    '''
    bj_as 内容是
    [<a>编剧</a>,<a></a>,<a></a>,<a></a>,]
    需要进一步提取
    '''
    p = re.compile(r'>(.*)<')
    bj = [p.findall(bj) for bj in bj_as]
    '''
    p.findall也会产生数组,需要去掉括号,只有有数据才能去掉
    '''
    bj = [b[0].strip() for b in bj if len(b)>0]#编剧的最终结果

    '''
    语言
    <span class="pl">语言:</span> 英语 / 捷克语 / 乌克兰语 / 法语<br/>
    '''
    pattern_lang = re.compile(r'<span class="pl">语言:</span>(.*)<br/>')
    langs = pattern_lang.findall(html)
    if len(langs)>0:
        langs = langs[0]
        langs = langs.split('/')
        langs = [l.strip() for l in langs]
    runtime = response.xpath('//span[@property="v:runtime"]/@content').extract_first()
    '''
    上映日期也有多个
    '''
    releasedates = response.xpath('//span[@property="v:initialReleaseDate"]/text()').extract()
    '''
    标签
    '''
    tags = response.xpath('//div[@class="tags-body"]/a/text()').extract()

    ##这里不能用return
    yield {"title":title,"year":year,"directors":directors,"score":score,"votes":votes,
    "actors":actors,"genres":genres,"nations":nations,"bj":bj,"langs":langs,"runtime":runtime,
    "releasedates":releasedates,"url":response.url
    }

上面的代码确实能正常工作,但是有个缺点就是太慢,不到五万个页面就要几个小时,显然瓶颈在分析这一块。性能问题会在下面一个例子中讨论。

2

性能问题确实是个大问题,在满足能爬取的情况下,速度要优化。这几天抓取一个AV网站,没错AV网站的种子文件。先抓取文章列表,再抓取每个详细页面,访问种子下载页面,最后下载里面的种子文件。

  • 方法一
    这个代码很简单使用的是requests来下载文件,里面的下载功能代码就是从requests教程中拷贝出来的。
def process_item(self, item, spider):
        try:
            bt_urls = item['bt_urls']
            if not os.path.exists(self.dir_path):
                os.makedirs(self.dir_path)
            '''
            检查文件夹是否存在这段代码应该放到open_spider中去才是合适的,启动检查一下后面就不管了
            '''
            for url in bt_urls:
                response = requests.get(url,stream=True)
                attachment = response.headers['Content-Disposition']
                pattern = re.compile(r'filename="(.+)"')
                filename = pattern.findall(attachment)[0]
                filepath = '%s/%s' % (self.dir_path,filename)
                with open(filepath, 'wb') as handle:
                    #response = requests.get(image_url, stream=True)
                    for block in response.iter_content(1024):
                        if not block:
                            break
                        handle.write(block)
                '''
                整个代码肯定会严重影响爬虫的运行速度,是否考虑多进程方法
                '''
        except Exception as e:
            print(e)
        return item

bt_url种子的链接就放在bt_urls,由于是从下载页面返回的item,实际中最多只有一个链接。这个代码运行起来没什么问题,但是速度相当慢。scrapy使用的是异步网络框架,但是requests是实实在在的同步方法,单线程的情况下必然影响到整个系统的执行。必须要突破这个瓶颈,实际中要先考虑代码能正确运行再考虑其它方面。

  • 方法二
    既然在本线程中直接下载会造成线程阻塞,那开启一个新的进程如何。
class DownloadBittorrent2(object):

    def __init__(self, dir_path):
        self.dir_path = dir_path
        # self.mongo_db = mongo_db

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            dir_path =crawler.settings.get('DIR_PATH'),
        )

    def open_spider(self, spider):
        if not os.path.exists(self.dir_path):
            os.makedirs(self.dir_path)

    def close_spider(self, spider):
        pass

    def downloadprocess(self,url):
        try:
            response = requests.get(url,stream=True)
            attachment = response.headers['Content-Disposition']
            pattern = re.compile(r'filename="(.+)"')
            filename = pattern.findall(str(attachment))[0]#这里attachment是bytes必须要转化
            filepath = '%s/%s' % (self.dir_path,filename)
            with open(filepath, 'wb') as handle:
                #response = requests.get(image_url, stream=True)
                for block in response.iter_content(1024):
                    if not block:
                        break
                    handle.write(block)
        except Exception as e:
            print(e)
    def process_item(self, item, spider):

        bt_urls = item['bt_urls']
        if len(bt_urls)>0:#最多只有一个url
            p = Process(target=self.downloadprocess,args=(bt_urls[0],))
            p.start()

        return item

这个代码也能正常工作,但是报错,直接导致服务器挂了。
HTTPConnectionPool(host='taohuabbs.info', port=80): Max retries exceeded with url: /forum.php?mod=attachment&aid=MjAxNzk 0fDA0OTkxZjM0fDE0ODU4NjY0OTZ8MHwxMzMzNDA= (Caused by NewConnectionError('<requests.packages.urllib3.connection.HTTPConne ction object at 0x00000000041F9048>: Failed to establish a new connection: [WinError 10060]由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。',))
这个可能跟设置的延迟有关系(我就没有设置延迟),反正就是把服务器弄死了。还有就是requests在这种异常情况下容错能力有问题。

  • 方法三
    既然scrapy自带了一个Filespipeline,那么是不是可以考虑用这个来下载呢!可以试试!
class DownloadBittorrent3(FilesPipeline):
    def get_media_requests(self, item, info):
        for file_url in item['bt_urls']:
            yield scrapy.Request(file_url)

代码报错了,原因是文件名打不开。这个就涉及到如何命名下载文件名的问题。如果链接中带*.jpg这样类似的名字,程序不会有问题,如果不是会怎么样,链接中可能出现操作系统不允许在文件名中出现的字符,这就会报错。我对系统自带的这个pipeline了解甚少,就没有继续研究。
还有一点我希望文件名来自于服务器的反馈,对于下载文件服务器可能会把文件名发过来,这个就在headers的Content-Disposition字段中。也就是是说我必须要先访问网络之后才能确定文件名。

  • 方法四
    前面我们都使用了pipeline来处理,实际上我们完全可以不用pipeline而直接在spider中处理。
    def download(self,response):
        '''
        在爬取过程中发现有可能返回不是torrent文件,这时候要考虑容错性问题,虽然爬虫并不会挂掉
        '''
        attachment = response.headers['Content-Disposition']
        pattern = re.compile(r'filename="(.+)"')
        filename = pattern.findall(attachment.decode('utf-8'))[0]
        filepath = '%s/%s' % (self.settings['DIR_PATH'],filename)
        with open(filepath, 'wb') as handle:
            handle.write(response.body)

这种方法性能不错,对比前面50/min速度,这个可以达到100/min。其实我们可以更快。

3

在实际的下载中,我们要充分利用scrapy的网络下载框架,这个性能好容错性高,而且也好排错。上面的10060错误,我估计放在http中可能就是503(服务器无法到达)。
前面的方法都在单线程中运作,虽然后面有多进程版的下载代码,由于没有scrapy稳定所以我考虑用多个爬虫来实现。如果启动两个scrapy爬虫,一个负责爬页面,一个负责下载,这样的效率应该会高不少。虽然前面的笔记中有提到相关代码,使用redis来实现分布式。当然在单机上称不上分布式,但是使用redis作为进程间通讯手段确实极好的,不管是多进程还是分布式都能非常高效的工作。github上有基于redis版本的scrapy,这里我的想法是第一个爬虫负责爬页面的属于一般爬虫(使用原版的scrapy),而第二个爬虫使用基于redis的爬虫。

  • 1 scrapy-redis安装
    pip install scrapy-redis
    安装方法倒是很简单,但是这个代码比较旧了,版本是0.6.3,这个版本在python3.5上工作不正常(出错跟转码有关str,具体情况不懂),处理的办法就是把0.6.7的代码下载下来直接覆盖就可以了(反正我也看不懂代码,覆盖了能工作)。
  • 2 配置
    scrapy-redis的配置还是在settings中,参考文档
    文档中有几个必须配置的参数:
    SCHEDULER = "scrapy_redis.scheduler.Scheduler"
    DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
    后面还可以配置redis服务器端口号,还有redis服务器地址。
    REDIS_START_URLS_BATCH_SIZE
    上面的参数对代表每次redis从redis服务器中获取的链接数量,这个调高可能会增加性能。
  • 3 页面爬虫
class BtSpiderEx(scrapy.Spider):
    name = 'btspiderex'
    start_urls = ['http://taohuabbs.info/forum-181-1.html']
    def parse(self,response):
        urls = response.xpath('//a[@onclick="atarget(this)"]/@href').extract()
        for url in urls:
            yield scrapy.Request(response.urljoin(url),callback=self.parsedetail)

        page_urls = response.xpath('//div[@class="pg"]/a/@href').extract()
        for url in page_urls:
            yield scrapy.Request(response.urljoin(url),callback=self.parse)

    def parsedetail(self,response):
        hrefs = response.xpath('//p[@class="attnm"]/a/@href').extract()
        for h in hrefs:
            yield scrapy.Request(response.urljoin(h),callback=self.parsedown)

    def parsedown(self,response):
        '''
        其实每次只能分析出一个bt链接
        '''
        bt_urls = response.xpath('//div[@style="padding-left:10px;"]/a/@href').extract()
        yield {'bt_urls':bt_urls}

页面爬虫代码其实相对于前面的实现,变得更加简单,这里把将下载链接推送到redis服务器的任务交给pipeline。

class DownloadBittorrent(object):
    def __init__(self, dir_path):
        self.dir_path = dir_path
    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            dir_path =crawler.settings.get('DIR_PATH'),
        )
    def open_spider(self, spider):
        if not os.path.exists(self.dir_path):
            os.makedirs(self.dir_path)
        self.conn = redis.Redis(port=6666)
    def close_spider(self,spdier):
        pass
    def process_item(self, item, spider):

        bt_urls = item['bt_urls']
        for url in bt_urls:
            self.conn.lpush('redisspider:start_urls',url)
        return item

open_spider在爬虫启动的时候启动,这里就可以打开redis和建立下载文件夹。redisspider:start_urls这个是redis队列名,缺省情况下scrapy-redis爬虫的队列就是爬虫名+start_urls。

  • 4 下载爬虫
    下载爬虫只负责从redis获取链接然后下载。
from scrapy_redis.spiders import RedisSpider
import re
class DistributeSpider(RedisSpider):
    name = 'redisspider'
    def parse(self,response):
        DIR_PATH = "D:/bt"
        if 'Content-Disposition' in response.headers:
            attachment = response.headers['Content-Disposition']
            pattern = re.compile(r'filename="(.+)"')
            filename = pattern.findall(attachment.decode('utf-8'))[0]
            filepath = '%s/%s' % (DIR_PATH,filename)#DIR_PATH = "D:/bt"
            with open(filepath, 'wb') as handle:
                handle.write(response.body)

settings.py配置,只列出了主要参数,这里修改了默认端口号:

SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
REDIS_PORT = 6666
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容