Python高级特性与网络爬虫(四):异步多协程维护代理池

在做爬虫的时候,如果我们过于频繁地访问某个网站,可能会导致我们的ip被封掉,在这个时候我们需要代理来伪装自己的ip,网上的代理服务有很多,免费的和收费的都有。这些ip有的时候不太稳定,所以我们需要维护一个能够实时从代理服务网站上爬取代理ip并测试其是否可用,选取可用ip,这就需要爬取和测试一个代理池。这篇博文讲述的就是这样一个代理池的构造方法,从一个免费代理网站http://www.66ip.cn上,主要涉及到的有python与redis缓存数据库的交互,async/await异步多线程编程以及元类(metaclass)的构建,主要包含有如下三个基本模块:

  1. 存储模块:负责存储抓取下来的代理,为了保证抓取下来的ip不会重复,同时能实时标识每个ip的可用情况,这里采用Redis的Sorted Set即有序集合来存储
  2. 获取模块:负责定时从代理网站上抓取代理ip及其端口号
  3. 检测模块:定时检测存储在Redis中的ip代理是否可用,这里需要一个检测链接,一般是你要用这些代理ip爬取哪个网站的内容,就用该网站作为检测链接,这里我们用百度作为检测链接。我们用一个分数来标识每个ip代理的状态,检测一次,如果代理可用或者不可用就相应地增减分数
    下面将分别介绍各个模块的Python代码

存储模块(Python和Redis交互)

存储模块的存储后端采用的是Redis的有序集合,集合的元素是一个个代理ip,每个代理ip都会有一个分数字段,该集合会根据每一个元素的分数对集合进行排序,数值小的排在前面,数值大的排在后面。一个代理的分数作为判断该代理是否可用的标志,100分为最高分,0分为最低分,分数设置的规则如下:新获取的代理初始分数为10,如果测试可用,则置为100,如果不可用,分数则减1,分数减到0之后该代理将被移除,模块代码如下所示,包括__init__()(建立和Redis连接),add()(向数据库中添加代理并设置分数),random()(随机获取代理,首先获取100分的代理,随机选择一个返回,如果不存在100分的代理,就按照排名获取,选取前100名,然后随机选择一个返回)等方法,涉及到的Redis库函数包括zscore,zadd,zrangebyscore,zrevrange,zincrby,zrem,zcard,具体用法参见注释

import redis
from random import choice

REDIS_HOST='localhost'
REDIS_PORT=6379
REDIS_PASSWORD=None #替换成你的redis数据库密码
REDIS_KEY='proxies'
class RedisClient(object):
    def __init__(self,host=REDIS_HOST,port=REDIS_PORT,password=REDIS_PASSWORD):
        self.db=redis.StrictRedis(host=host,port=port,password=password,decode_responses=True) #建立和Redis的连接

    def add(self,proxy,score=INITIAL_SCORE):
        if not self.db.zscore(REDIS_KEY,proxy): #Redis Zscore 命令返回有序集(有序集合名称为REDIS_KEY)中,成员的分数值。 如果成员元素不是有序集 key 的成员,或 key 不存在,返回 nil
            return self.db.zadd(REDIS_KEY,{proxy: score}) #添加代理,proxy为ip+端口,score为初始分数10

    def random(self):
        """
        get random proxy
        firstly try to get proxy with max score
        if not exists, try to get proxy by rank
        if not exists, raise error
        :return: proxy, like 8.8.8.8:8
        """
        # try to get proxy with max score
        #zrangebyscore(key min max),返回有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员,有序集成员按 score 值递增(从小到大)次序排列
        result = self.db.zrangebyscore(REDIS_KEY, MAX_SCORE, MAX_SCORE)
        if len(result):
            return choice(result)
        # else get proxy by rank
        # Zrevrange 命令返回有序集中,指定区间内的成员,成员的位置按分数值递减(从大到小)来排列(和zrangebyscore相反)
        result = self.db.zrevrange(REDIS_KEY, MIN_SCORE, MAX_SCORE)
        if len(result):
            return choice(result)
        # else raise error
        raise

    def decrease(self, proxy):
        """
        decrease score of proxy, if small than PROXY_SCORE_MIN, delete it
        :param proxy: proxy
        :return: new score
        """
        score = self.db.zscore(REDIS_KEY, proxy)
        # current score is larger than PROXY_SCORE_MIN
        if score and score > MIN_SCORE:
            print('代理',proxy,'当前分数',score,'减1')
            #zincrby(key,value,proxy):对有序集合中的proxy元素的值执行加value的操作
            return self.db.zincrby(REDIS_KEY,-1,proxy)
        # otherwise delete proxy
        else:
            print('代理', proxy, '当前分数', score, '移除')
            #zrem(key,proxy):从有序集合中移除元素proxy
            return self.db.zrem(REDIS_KEY, proxy)

    def exists(self, proxy):
        """
        if proxy exists
        :param proxy: proxy
        :return: if exists, bool
        """
        return not self.db.zscore(REDIS_KEY, proxy) is None

    def max(self, proxy):
        """
        set proxy to max score
        :param proxy: proxy
        :return: new score
        """
        print('代理', proxy, '可用,设置为', MAX_SCORE)
        return self.db.zadd(REDIS_KEY, {proxy: MAX_SCORE})

    def count(self):
        """
        get count of proxies
        :return: count, int
        """
        return self.db.zcard(REDIS_KEY) #zcard返回集合key中的元素数量

    def all(self):
        """
        get all proxies
        :return: list of proxies
        """
        return self.db.zrangebyscore(REDIS_KEY, MIN_SCORE, MAX_SCORE)

    def batch(self, start, end):
        """
        get batch of proxies
        :param start: start index
        :param end: end index
        :return: list of proxies
        """
        return self.db.zrevrange(REDIS_KEY, start, end - 1)

获取模块(元类metaclass)

获取模块定义一个crawler类来从http://www.66ip.cn中爬取ip及其端口号,我们首先来看一下www.66ip.cn的网页结构,如下图所示,ip和端口号都在表格中,翻页则是访问http://www.66ip.cn/2.html即可访问第二页表格的代理。
Python高级特性与网络爬虫(四):异步多协程维护代理池_第1张图片
获取模块的Crawler类的相关代码如下所示,这里借助了元类(metaclass)来实现获取类Crawler中所有以crawl开头的方法。metaclass,直译为元类,简单的解释就是:当我们定义了类以后,就可以根据这个类创建出实例,先定义类,然后创建实例。但是如果我们想创建出类呢?那就必须根据metaclass创建出类,所以:先定义元类(不自定义时,默认用type),然后创建类。metaclass的原理其实是这样的:当定义好类之后,创建类的时候其实是调用了type的__new__方法为这个类分配内存空间,Crawler的metaclass参数设置为ProxyMetaClass,Python解释器在创建Crawler时,要执行ProxyMetaClass.__new__()来创建,在此,我们可以修改类的定义,attrs为类的属性,我们为attrs添加两个属性__CrawlFunc__(包含Craweler中所有以Crawl开头的方法名称)和__CrawlFuncCount__(Craweler中以Crawl开头的方法的数量),之后返回修改后的定义return type.__new__(cls,name,bases,attrs),创建类的时候其实是调用了type的__new__\方法,此时attrs为修改后的attrs。这样做的目的是为了方便Crawler类的扩展,这里只定义了一个爬取66ip的方法crawl_daili66,爬取前四页的ip,以后想要添加爬取别的代理网站的ip的方法就可以直接添加以crawl开头的方法,能够返回ip:port的字符串即可(yield ‘:’.join([ip,port])),不用关系类其他部分的实现逻辑。最后通过Getter类来创建RedisClient()和Crawler()实例,将爬取的代码添加到有序集合proxies中。

import requests
from bs4 import BeautifulSoup

POOL_UPPER_THRESHOLD=10000 #代理池中最大能够存储的代理数量

class ProxyMetaClass(type):  #元类
    def __new__(cls,name,bases,attrs): #attrs参数包含了类的一些属性
        count=0
        attrs['__CrawlFunc__']=[]
        for k,v in attrs.items():   #遍历attrs即可获得类的所有方法信息,判断方法开头是否为crawl
            if 'crawl' in k:
                attrs['__CrawlFunc__'].append(k)
                count+=1
        attrs['__CrawlFuncCount__']=count
        return type.__new__(cls,name,bases,attrs)

class Crawler(object,metaclass=ProxyMetaClass):
    def get_proxies(self,callback):
        proxies=[]
        for proxy in eval("self.{}()".format(callback)): #eval执行所有的callback方法
            print('成功获取到代理',proxy)
            proxies.append(proxy)
        return proxies

    def crawl_daili66(self,page_count=4):
        start_url="http://www.66ip.cn/{}.html"
        urls=[start_url.format(page) for page in range(1,page_count+1)]
        for url in urls:
            r = requests.get(url, headers=header)
            soup = BeautifulSoup(r.text, 'lxml')
            length = len(soup.find_all("table")[2].find_all("td"))
            for i in range(5,length, 5):
                ip = soup.find_all("table")[2].find_all("td")[i].text
                port = soup.find_all("table")[2].find_all("td")[i + 1].text
                yield ':'.join([ip,port])
class Getter(): #动态调用Crawl类中以crawl开头的方法
    def __init__(self):
        self.redis=RedisClient()
        self.crawler=Crawler()

    def is_over_threshold(self):
        if self.redis.count()>=POOL_UPPER_THRESHOLD:
            return True
        else:
            return False

    def run(self):
        print("获取器开始执行")
        if not self.is_over_threshold():
            for callback_label in range(self.crawler.__CrawlFuncCount__):
                callback=self.crawler.__CrawlFunc__[callback_label]
                proxies=self.crawler.get_proxies(callback)
                for proxy in proxies:
                    self.redis.add(proxy)

检测模块(async/await异步多协程)

成功将各个网站的代理爬取下来并存入Redis有序集合后,需要一个检测模块来对所有的代理进行多轮检测,根据检测结果调整代理的相应分数。由于代理的数量比较多,每次测试代理ip的请求又是一个比较耗时的操作,所以我们使用异步请求库aiohttp来进行检测,该python库使用pip安装即可。所谓的异步请求,是相对于同步请求requests来说的,我们之前使用requests发出一个get请求,程序需要等网页加载完成之后才能继续执行,也就是这个过程会阻塞等待响应,我们在这个等待时间里可以去做其他事情,类似于多进程多线程那样的操作,异步请求库aiohttp就支持这样的操作,在请求发出之后,程序可以执行其他事情,比如对其他代理发起检测之类的。最终实现检测功能的类代码如下所示,通过async关键字使test_single_proxy成为一个协程,异步上下文管理器指的是在异步请求aiohttp.ClientSession()的enter和exit方法处能够暂停执行的上下文管理器。最后在run()方法中,通过asyncio.get_event_loop()创建一个事件loop,loop.run_until_complete(future)能够将future注册到循环当中,而asyncio.wait(tasks)则是将各个协程包装成为一个future(future可以理解为是一个内部包含众多协程的大协程),最后实例化爬取存储模块Getter()和Tester(),循环执行爬取存储和测试操作。

import aiohttp
import asyncio
import time

VALID_STATUS_CODES=[200]
TEST_URL='http://www.baidu.com'
BATCH_TEST_SIZE=100

class Tester(object):
    def __init__(self):
        self.redis=RedisClient()

    async def test_single_proxy(self,proxy): #使方法变为一个协程的关键字
        conn=aiohttp.TCPConnector(ssl=False)
        async with aiohttp.ClientSession(connector=conn) as session: #类似requests中的Session对象
            try:
                if isinstance(proxy,bytes):
                    proxy=proxy.decode('utf-8')
                real_proxy='http://'+proxy
                print('正在测试',proxy)
                async with session.get(TEST_URL,proxy=real_proxy,timeout=15) as response:
                    if response.status in VALID_STATUS_CODES:
                        self.redis.max(proxy)
                        print('代理可用',proxy)
                    else:
                        self.redis.decrease(proxy)
                        print("请求响应码不合法",proxy)
            except:
                self.redis.decrease(proxy)
                print('代理请求失败',proxy)
    def run(self):
        print('测试器开始运行')
        try:
            proxies=self.redis.all()
            loop=asyncio.get_event_loop()
            for i in range(0,len(proxies),BATCH_TEST_SIZE): 
                test_proxies=proxies[i:i+BATCH_TEST_SIZE]
                tasks=[self.test_single_proxy(proxy) for proxy in test_proxies]
                loop.run_until_complete(asyncio.wait(tasks))
                time.sleep(5)
        except Exception as e:
            print('测试器发生错误',e.args)

if __name__ =='__main__':
	Get=Getter()
	test=Tester()
	while(True):
		Get.run()
		test.run()

通过async/await多协程爬取微博图片

为了加深对async/await异步多协程机制的理解,针对https://blog.csdn.net/weixin_41977332/article/details/105591034中多进程爬取微博用户图片程序改造成了多协程爬取,代码如下所示,异步上下文管理器async with不能使用普通的同步请求的文件打开函数open(),有相应的异步文件操作库aiofiles可以使用。每个协程在执行到await f.write(r.content)便可以在等待IO操作时切换下一个协程执行。

#author:xingfengxueyu
#Date:2020/05/01

from urllib.parse import urlencode
import requests
import time
import os
import asyncio
import aiofiles
base_url='https://m.weibo.cn/api/container/getIndex?'

async def get_pics(item):
    global lock
    if item.get('mblog')!=None:
        item=item.get('mblog')
    else:
        return
    item = item.get('pics')
    if item != None:
        for pic in item:
            if pic.get('large') != None:
                url=pic.get('large').get('url')
                title=url.split('/')[-1]
                r=requests.get(url)
                async with aiofiles.open(title,'wb') as f: #async和await的配合 
                    #print("开始写入图片{}".format(title))
                    await f.write(r.content)

def get_all_weibo(since_id):
    if since_id==None:
        return None
    headers={
        'Referer': 'https://m.weibo.cn/u/6235323673?from=myfollow_all&is_all=1&sudaref=login.sina.com.cn',
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.25 Safari/537.36 Core/1.70.3706.400 SLBrowser/10.0.4040.400',
        'X-Requested-With': 'XMLHttpRequest',
    }
    params={
        'from': 'myfollow_all',
        'is_all': '1',
        'sudaref': 'login.sina.com.cn',
        'type': 'uid',
        'value': '6235323673',
        'containerid': '1076036235323673',
        'since_id': since_id,
    }
    url=base_url+urlencode(params)
    r=requests.get(url,headers=headers)
    return r.json().get('data').get('cards')

if __name__ =='__main__':
    items=get_all_weibo('4494883576803454')
    start=time.time()
    loop=asyncio.get_event_loop()
    while(1):
        tasks=[get_pics(item) for item in items]
        loop.run_until_complete(asyncio.wait(tasks))
        for i in range(len(items)):  #寻找最后一条含有mblog字段的card,取其mblog字段中的id作为下一次循环的id
            if items[len(items)-1-i].get('mblog')!=None:
                since_id=items[-1].get('mblog').get('id')
                break
        items=get_all_weibo(since_id)
        time.sleep(0.5)
        print(len(os.listdir('yaoyao')))
        if(len(os.listdir('yaoyao'))>200):
            break
        if items==None or len(items)<=1:
            break
        items=items[1:]
    print("运行时间:",time.time()-start)

你可能感兴趣的:(Python高级特性与网络爬虫)