28

Welcome to the D-age

对于网络上的公开数据,理论上只要由服务端发送到前端都可以由爬虫获取到。但是Data-age时代的到来,数据是新的黄金,毫不夸张的说,数据是未来的一切。基于统计学数学模型的各种人工智能的出现,离不开数据驱动。数据采集、清洗是最末端的技术成本,网络爬虫也是基础采集脚本。但是有几个值得关注的是:

  • 对于实时变化的网络环境,爬虫的持续有效性如何保证
  • 数据采集、清洗规则的适用范围
  • 数据采集的时间与质量--效率
  • 爬与反爬的恩怨
  • 爬虫的法律界限

法律的边界,技术无罪

对于上面几个关注点,我最先关注的便是爬虫的法律界限 ,我曾经咨询过一个律师:

Q: 老师,我如果用爬虫爬取今日头条这种类型网站的千万级公开数据,算不算违法呢?
A: 爬取的公开数据不得进行非法使用或者商业利用

简单的概括便是爬虫爬取的数据如果进行商业出售或者有获利的使用,便构成了“非法使用”。而一般的爬虫程序并不违法,其实这是从法律专业的一方来解读,如果加上技术层面的维度,那么应该从这几方面考虑:

  • 爬取的数据量
  • 爬取数据的类型(数据具有巨大的商业价值,未经对方许可,任何人不得非法获取其数据并用于经营行为
  • 爬取的数据用途 (同行竞争?出售?经营?分析?实验?...)
  • 是否遵循网站的robots.txt 即 机器人协议
  • 爬取行为是否会对对方网站造成不能承受的损失(大量的爬取请求会把一个小型网站拖垮)

其实爬虫构成犯罪的案例是开始增多的,相关新闻:

  1. 当爬虫遇上法律会有什么风险?
  2. 程序员爬虫竟构成犯罪?
  3. 爬虫相关法律知识

如果你的上级或公司要求你爬取某些网站的大量公开数据,你会怎么办呢?可以参考第2条新闻。法律矛盾点关键在于前面考虑的前三点,如果是个人隐私数据,是不能爬取的,如果是非公开数据,是不能爬取的,而对于其他大量的公开数据爬取,看人家查不查的到你,要不要起诉你。技术在你的手上,非法与否在于你怎么去用。最好的爬取道德原则是:

  • 减少并发请求
  • 延长请求间隔
  • 不进行公开出售数据
  • 遵循网站 robots协议

当然,反爬最有效的便(目的均在于拦截爬虫进入网站数据范围)是:

  • 要求用户密码+验证码
  • 加密数据
  • js混淆
  • css混淆
  • 针对IP请求频率封锁
  • 针对cookie、session单个账户请求频率封锁单日请求次数
  • 对关键数据进行拆分合并
  • 对爬虫投毒(返回假数据)
  • 完善robots.txt
  • 识别点击九宫图中没有包含xxx的图片等(终极验证码)
  • 设置黑白名单、IP用户组等

工欲善其事

针对网站的公开数据进行爬取,我们一般都要先对网站数据进行分析,定位,以确定其采集规则,如果网站设置了访问权限,那么便不属于我们的爬虫采集范围了:)
分析好采集规则,写好了采集数据持久化(存入数据库、导出为word、excel、csv、下载等)的相关代码,整个爬虫运行正常。那么怎样才能提高采集速度呢?

  • 多进程采集
  • 多线程采集
  • 异步协程采集
  • 多进程 + 多线程采集
  • 多进程 + 异步协程采集
  • 分布式采集

异步爬虫是同步爬虫的升级版,在同步爬虫中,无论你怎么优化代码,同步IO的阻塞是最大的致命伤。同步阻塞会让采集任务一个个排着长队领票等待执行。而异步采集不会造成IO阻塞,充分利用了IO阻塞任务的等待时间去执行其他任务。

在IO 模型中,只有IO多路复用(I/O multiplexing){在内核处理IO请求结果为可读或可写时调用回调函数} 不阻塞 “内核拷贝IO请求数据到用户空间”这个过程,实现异步IO操作。

同步爬虫

一般的同步爬虫,我们可以写一个,(以爬取图片网站图片为例),我们来看看其下载该网址所有图片所花费的时间:

以下代码为后面多个例程的共同代码:
#coding:utf-8
import time
from lxml import etree
import urllib.request as request

#目标网址
url = 'http://www.quanjing.com/creative/SearchCreative.aspx?id=7'

def download_one_pic(url:str,name:str,suffix:str='jpg'):
    #下载单张图片
    path = '.'.join([name,suffix])
    response = request.urlopen(url)
    wb_data = response.read()
    with open(path,'wb') as f:
        f.write(wb_data)

def download_many_pic(urls:list):
    #下载多张图片
    start = time.time()
    for i in urls:
        ts = str(int(time.time() * 1000))
        download_one_pic(i, ts)
    end = time.time()
    print(u'下载完成,%d张图片,耗时:%.2fs' % (len(urls), (end - start)))

def get_pic_urls(url:str)->list:
    #获取页面所有图片链接
    response = request.urlopen(url)
    wb_data = response.read()
    html = etree.HTML(wb_data)
    pic_urls = html.xpath('//a[@class="item lazy"]/img/@src')
    return pic_urls

def allot(pic_urls:list,n:int)->list:
    #根据给定的组数,分配url给每一组
    _len = len(pic_urls)
    base = int(_len / n)
    remainder = _len % n
    groups = [pic_urls[i * base:(i + 1) * base] for i in range(n)]
    remaind_group = pic_urls[n * base:]
    for i in range(remainder):
        groups[i].append(remaind_group[i])
    return [i for i in groups if i]

同步爬虫:

def crawler():
    #同步下载
    pic_urls = get_pic_urls(url)
    download_many_pic(pic_urls)

执行同步爬虫,

crawler()

输出(时间可能不一样,取决于你的网速):

下载完成,196张图片,耗时:49.04s

在同一个网络环境下,排除网速时好时坏,可以下载多几次取平均下载时间,在我的网络环境下,我下载了5次,平均耗时约55.26s

多进程爬虫

所以为了提高采集速度,我们可以写一个多进程爬虫(以爬取图片网站图片为例):
为了对应多进程的进程数n,我们可以将图片链接列表分成n组,多进程爬虫:

from multiprocessing.pool import Pool
def multiprocess_crawler(processors:int):
    #多进程爬虫
    pool = Pool(processors)
    pic_urls = get_pic_src(url)
    #对应多进程的进程数processors,我们可以将图片链接列表分成processors组
    url_groups = allot(pic_urls,processors)
    for i in url_groups:
        pool.apply_async(func=download_many_pic,args=(i,))
    pool.close()
    pool.join()

执行爬虫,进程数设为4,一般是cpu数量:

multiprocess_crawler(4)

输出:

下载完成,49张图片,耗时:18.22s
下载完成,49张图片,耗时:18.99s
下载完成,49张图片,耗时:18.97s
下载完成,49张图片,耗时:19.51s

可以看出,多进程比原先的同步爬虫快许多,整个程序耗时19.51s,为什么不是同步爬虫的55s/4 ≈ 14s呢?因为进程间的切换需要耗时。
如果把进程数增大,那么:

进程数:10 , 耗时:12.3s
进程数:30 , 耗时:2.81s
进程数:40 , 耗时:11.34s

对于多进程爬虫来说,虽然实现异步爬取,但也不是越多进程越好,进程间切换的开销不仅会让你崩溃,有时还会让你的程序崩溃。一般用进程池Pool维护,Pool的processors设为CPU数量。进程的数量设置超过100个便让我的程序崩溃退出。使用进程池可以保证当前在跑的进程数量控制为设置的数量,只有池子没满才能加新的进程进去。

多线程爬虫

多线程版本可以在单进程下进行异步采集,但线程间的切换开销也会随着线程数的增大而增大。当线程间需要共享变量内存时,此时会有许多不可预知的变量读写操作发生,python为了使线程同步,给每个线程共享变量加了全局解释器锁GIL。而我们的爬虫不需要共享变量,因此是线程安全的,不用加锁。多线程版本:

import random
from threading import Thread

def run_multithread_crawler(pic_urls:list,threads:int):
    begin = 0
    start = time.time()
    while 1:
        _threads = []
        urls = pic_urls[begin:begin+threads]
        if not urls:
            break
        for i in urls:
            ts = str(int(time.time()*10000))+str(random.randint(1,100000))
            t = Thread(target=download_one_pic,args=(i,ts))
            _threads.append(t)
        for t in _threads:
            t.setDaemon(True)
            t.start()
        for t in _threads:
            t.join()
        begin += threads
    end = time.time()
    print(u'下载完成,%d张图片,耗时:%.2fs' % (len(pic_urls), (end - start)))

def multithread_crawler(threads:int):
    pic_urls = get_pic_src(url)
    run_multithread_crawler(pic_urls,threads)

并发线程数太多会让我们的系统开销越大,使程序花费时间越长,同时也会增大目标网站识别爬虫机器行为的几率。因此设置好一个适当的线程数以及爬取间隔是良好的爬虫习惯。
执行多线程爬虫,设置线程数为50

multithreads_crawler(50)

输出:

下载完成,196张图片,耗时:3.10s

增大线程数,输出:

线程数:50,耗时:3.10s
线程数:60,耗时:3.07s
线程数:70,耗时:2.50s
线程数:80,耗时:2.31s
线程数:120,耗时:3.67s

可以看到,线程可以有效的提高爬取效率,缩短爬取时间,但必须是一个合理的线程数,越多有时并不是越好的,一般是几十到几百个之间,数值比多进程进程数大许多。

异步协程爬虫

Python3.5引入了async/await 异步协程语法。详见PEP492
由于asyncio提供了基于socket的异步I/O,支持TCP和UDP协议,但是不支持应用层协议HTTP,所以需要安装异步http请求的aiohttp模块
单进程下的异步协程爬虫:

import asyncio
from asyncio import Semaphore
from aiohttp import ClientSession,TCPConnector

async def download(session:ClientSession,url:str,name:str,sem:Semaphore,suffix:str='jpg'):
    path = '.'.join([name,suffix])
    async with sem:
        async with session.get(url) as response:
            wb_data = await response.read()
            with open(path,'wb') as f:
                f.write(wb_data)

async def run_coroutine_crawler(pic_urls:list,concurrency:int):
    # 异步协程爬虫,最大并发请求数concurrency
    tasks = []
    sem = Semaphore(concurrency)
    conn =TCPConnector(limit=concurrency)
    async with ClientSession(connector=conn) as session:
        for i in pic_urls:
            ts = str(int(time.time() * 10000)) + str(random.randint(1, 100000))
            tasks.append(asyncio.create_task(download(session,i,ts,sem)))
        start = time.time()
        await asyncio.gather(*tasks)
        end = time.time()
        print(u'下载完成,%d张图片,耗时:%.2fs' % (len(pic_urls), (end - start)))

def coroutine_crawler(concurrency:int):
    pic_urls = get_pic_src(url)
    loop = asyncio.get_event_loop()
    loop.run_until_complete(run_coroutine_crawler(pic_urls,concurrency))
    loop.close()

执行异步协程爬虫,设置最大并发请求数为100:

coroutine_crawler(100)

输出:

下载完成,196张图片,耗时:2.27s

可以看出,异步多协程的下载请求效率并不比多线程差,由于磁盘IO读写阻塞,所以还可以进一步优化,使用aiofiles
针对比较大的多媒体数据下载,异步磁盘IO可以使用aiofiles,以上述例子download可以改为:

import aiofiles
async def download(session:ClientSession,url:str,name:str,sem:Semaphore,suffix:str='jpg'):
    path = '.'.join([name,suffix])
    async with sem:
        async with session.get(url) as response:
           async with aiofiles.open(path,'wb') as fd:
            while 1:
                wb_data_chunk = await response.content.read(1024)
                if not wb_data_chunk:
                    break
                await fd.write(wb_data_chunk)

多进程 + 多线程 爬虫

实际采集大量数据的过程中,往往是多种手段来实现爬虫,这样可以充分利用机器CPU,节省采集时间。
下面使用多进程(进程数为CPU数,4)+ 多线程 (线程数设为50)来对例子进行更改(上面各个例子导入的模块默认使用):

def mixed_process_thread_crawler(processors:int,threads:int):
    pool = Pool(processors)
    pic_urls = get_pic_src(url)
    url_groups = allot(pic_urls,processors)
    for group in url_groups:
        pool.apply_async(run_multithread_crawler,args=(group,threads))
    pool.close()
    pool.join()

执行爬虫:

mixed_process_thread_crawler(4,50)

输出:

下载完成,49张图片,耗时:2.73s
下载完成,49张图片,耗时:2.76s
下载完成,49张图片,耗时:2.76s
下载完成,49张图片,耗时:2.76s

采集时间与异步协程和多线程并无多大的差异,可以使用更大数据量做实验区分。因为多进程+多线程,CPU切换上下文也会造成一定的开销,所以进程数与线程数不能太大,并发请求的时间间隔也要考虑进去。

多进程 + 异步协程 爬虫

使用多进程(进程数为CPU数,4)+ 异步协程(最大并发请求数设为50)来对例子进行更改(上面各个例子导入的模块默认使用):

def _coroutine_crawler(pic_urls:list,concurrency:int):
    loop = asyncio.get_event_loop()
    loop.run_until_complete(run_coroutine_crawler(pic_urls, concurrency))
    loop.close()

def mixed_process_coroutine_crawler(processors:int,concurrency:int):
    pool = Pool(processors)
    pic_urls = get_pic_src(url)
    url_groups = allot(pic_urls, processors)
    for group in url_groups:
        pool.apply_async(_coroutine_crawler, args=(group, concurrency))
    pool.close()
    pool.join()

执行爬虫 :

mixed_process_coroutine_crawler(4,50)

输出:

下载完成,49张图片,耗时:2.56s
下载完成,49张图片,耗时:2.54s
下载完成,49张图片,耗时:2.56s
下载完成,49张图片,耗时:2.62s

效果与多进程 + 多线程 爬虫差不多,但是CPU减少了切换线程上下文的开销,而是对每一个协程任务进行监视回调唤醒。使用IO多路复用的底层原理实现。

分布式采集

关于分布式采集将会单独写一章,使用Map-Reduce+redis来实现分布式爬虫。

轮子们,你们辛苦了

现实生活中的爬虫不止上面那些,但是基本的骨架是一样的,对于特定的网站需要制定特定的采集规则,所以通用的数据采集爬虫很难实现。所以针对某个网站的数据采集爬虫是需要定制的,但是在不同之中包含着许多的相同、重复性的过程,比如说采集流程,或者对请求头部的伪造,数据持久化的处理等,采集框架应运而生。Scrapy就是目前比较成熟的一个爬虫框架。它可以帮助我们大大减少重复性的代码编写,可以更好的组织采集流程。而我们只需要喝一杯咖啡,编写自己的采集规则,让Scrapy去给我们管理各种各样的爬虫,做些累活。如果你是一个爬虫爱好者,那么scrapy是你的不错选择。由于好奇scrapy的实现流程,所以我才开始打开他的源码学习。
有些人觉得scrapy太重,他的爬虫只需要简单的采集,自己写一下就可以搞定了。但如果是大量的爬虫采集呢?怎么去管理这些爬虫呢?怎样才能提高采集效率呀?
Scrapy helps~!!
另外还有另一个Python采集框架:pyspider。国人编写的,cool~
感谢轮子们的父母,还有那些辛苦工作的轮子们,你们辛苦了~

本文所用代码 均在GitHub上,地址:这里


2end0
186 声望27 粉丝