我们在上一节了解了代理的设置方法,利用代理我们可以解决目标网站封 IP 的问题,而在网上又有大量公开的免费代理,其中有一部分可以拿来使用,或者我们也可以购买付费的代理 IP,价格也不贵。但是不论是免费的还是付费的,都不能保证它们每一个都是可用的,毕竟可能其他人也可能在用此 IP 爬取同样的目标站点而被封禁,或者代理服务器突然出故障或网络繁忙。一旦我们选用了一个不可用的代理,势必会影响我们爬虫的工作效率。
很多人学习python,不知道从何学起。
很多人学习python,掌握了基本语法过后,不知道在哪里寻找案例上手。
很多已经做案例的人,却不知道如何去学习更加高深的知识。
那么针对这三类人,我给大家提供一个好的学习平台,免费领取视频教程,电子书籍,以及课程的源代码!
QQ群:101677771
所以说,在用代理时,我们需要提前做一下筛选,将不可用的代理剔除掉,保留下可用代理,接下来在获取代理时从可用代理里面取出直接使用就好了。
所以本节我们来搭建一个高效易用的代理池。
要实现代理池我们首先需要成功安装好了 Redis 数据库并启动服务,另外还需要安装 Aiohttp、Requests、RedisPy、PyQuery、Flask 库,如果没有安装可以参考第一章的安装说明。
代理池要做到易用、高效,我们一般需要做到下面的几个目标:
以上便是设计代理的一些基本思路,那么接下来我们就设计一下整体的架构,然后用代码该实现代理池。
根据上文的描述,代理池的架构可以是这样的,如图 9-1 所示:
图 9-1 代理池架构
代理池分为四个部分,获取模块、存储模块、检测模块、接口模块。
接下来我们分别用代码来实现一下这四个模块。
存储模块
存储在这里我们使用 Redis 的有序集合,集合的每一个元素都是不重复的,对于代理代理池来说,集合的元素就变成了一个个代理,也就是 IP 加端口的形式,如 60.207.237.111:8888,这样的一个代理就是集合的一个元素。另外有序集合的每一个元素还都有一个分数字段,分数是可以重复的,是一个浮点数类型,也可以是整数类型。该集合会根据每一个元素的分数对集合进行排序,数值小的排在前面,数值大的排在后面,这样就可以实现集合元素的排序了。
对于代理池来说,这个分数可以作为我们判断一个代理可用不可用的标志,我们将 100 设为最高分,代表可用,0 设为最低分,代表不可用。从代理池中获取代理的时候会随机获取分数最高的代理,注意这里是随机,这样可以保证每个可用代理都会被调用到。
分数是我们判断代理稳定性的重要标准,在这里我们设置分数规则如下:
这是一种解决方案,当然可能还有更合理的方案。此方案的设置有一定的原因,在此总结如下:
以上便是代理分数的一个设置思路,不一定是最优思路,但个人实测实用性还是比较强的。
所以我们就需要定义一个类来操作数据库的有序集合,定义一些方法来实现分数的设置,代理的获取等等。
实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
MAX_SCORE = 100 MIN_SCORE = 0 INITIAL_SCORE = 10 REDIS_HOST = 'localhost' REDIS_PORT = 6379 REDIS_PASSWORD = None REDIS_KEY = 'proxies'
import redis from random import choice
class RedisClient(object): def __init__(self, host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD): """ 初始化 :param host: Redis 地址 :param port: Redis 端口 :param password: Redis密码 """ self.db = redis.StrictRedis(host=host, port=port, password=password, decode_responses=True)
def add(self, proxy, score=INITIAL_SCORE): """ 添加代理,设置分数为最高 :param proxy: 代理 :param score: 分数 :return: 添加结果 """ if not self.db.zscore(REDIS_KEY, proxy): return self.db.zadd(REDIS_KEY, score, proxy)
def random(self): """ 随机获取有效代理,首先尝试获取最高分数代理,如果不存在,按照排名获取,否则异常 :return: 随机代理 """ result = self.db.zrangebyscore(REDIS_KEY, MAX_SCORE, MAX_SCORE) if len(result): return choice(result) else: result = self.db.zrevrange(REDIS_KEY, 0, 100) if len(result): return choice(result) else: raise PoolEmptyError
def decrease(self, proxy): """ 代理值减一分,小于最小值则删除 :param proxy: 代理 :return: 修改后的代理分数 """ score = self.db.zscore(REDIS_KEY, proxy) if score and score > MIN_SCORE: print('代理', proxy, '当前分数', score, '减1') return self.db.zincrby(REDIS_KEY, proxy, -1) else: print('代理', proxy, '当前分数', score, '移除') return self.db.zrem(REDIS_KEY, proxy)
def exists(self, proxy): """ 判断是否存在 :param proxy: 代理 :return: 是否存在 """ return not self.db.zscore(REDIS_KEY, proxy) == None
def max(self, proxy): """ 将代理设置为MAX_SCORE :param proxy: 代理 :return: 设置结果 """ print('代理', proxy, '可用,设置为', MAX_SCORE) return self.db.zadd(REDIS_KEY, MAX_SCORE, proxy)
def count(self): """ 获取数量 :return: 数量 """ return self.db.zcard(REDIS_KEY)
def all(self): """ 获取全部代理 :return: 全部代理列表 """ return self.db.zrangebyscore(REDIS_KEY, MIN_SCORE, MAX_SCORE) |
首先定义了一些常量,如 MAX_SCORE、MIN_SCORE、INITIAL_SCORE 分别代表最大分数、最小分数、初始分数。REDIS_HOST、REDIS_PORT、REDIS_PASSWORD 分别代表了 Redis 的连接信息,即地址、端口、密码。REDIS_KEY 是有序集合的键名,可以通过它来获取代理存储所使用的有序集合。
接下来定义了一个 RedisClient 类,用以操作 Redis 的有序集合,其中定义了一些方法来对集合中的元素进行处理,主要功能如下:
定义好了这些方法,我们可以在后续的模块中调用此类来连接和操作数据库,非常方便。如我们想要获取随机可用的代理,只需要调用 random() 方法即可,得到的就是随机的可用代理。
获取模块
获取模块的逻辑相对简单,首先需要定义一个 Crawler 来从各大网站抓取代理,示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
import json from .utils import get_page from pyquery import PyQuery as pq
class ProxyMetaclass(type): def __new__(cls, name, bases, attrs): count = 0 attrs['__CrawlFunc__'] = [] for k, v in attrs.items(): 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)): print('成功获取到代理', proxy) proxies.append(proxy) return proxies
def crawl_daili66(self, page_count=4): """ 获取代理66 :param page_count: 页码 :return: 代理 """ start_url = 'http://www.66ip.cn/{}.html' urls = [start_url.format(page) for page in range(1, page_count + 1)] for url in urls: print('Crawling', url) html = get_page(url) if html: doc = pq(html) trs = doc('.containerbox table tr:gt(0)').items() for tr in trs: ip = tr.find('td:nth-child(1)').text() port = tr.find('td:nth-child(2)').text() yield ':'.join([ip, port])
def crawl_proxy360(self): """ 获取Proxy360 :return: 代理 """ start_url = 'http://www.proxy360.cn/Region/China' print('Crawling', start_url) html = get_page(start_url) if html: doc = pq(html) lines = doc('div[name="list_proxy_ip"]').items() for line in lines: ip = line.find('.tbBottomLine:nth-child(1)').text() port = line.find('.tbBottomLine:nth-child(2)').text() yield ':'.join([ip, port])
def crawl_goubanjia(self): """ 获取Goubanjia :return: 代理 """ start_url = 'http://www.goubanjia.com/free/gngn/index.shtml' html = get_page(start_url) if html: doc = pq(html) tds = doc('td.ip').items() for td in tds: td.find('p').remove() yield td.text().replace(' ', '')
|
为了实现灵活,在这里我们将获取代理的一个个方法统一定义一个规范,如统一定义以 crawl 开头,这样扩展的时候只需要添加 crawl 开头的方法即可。
在这里实现了几个示例,如抓取代理 66、Proxy360、Goubanjia 三个免费代理网站,这些方法都定义成了生成器,通过 yield 返回一个个代理。首先将网页获取,然后用PyQuery 解析,解析出IP加端口的形式的代理然后返回。
然后定义了一个 get_proxies() 方法,将所有以 crawl 开头的方法调用一遍,获取每个方法返回的代理并组合成列表形式返回。
你可能会想知道是怎样获取了所有以 crawl 开头的方法名称的。其实这里借助于元类来实现,定义了一个 ProxyMetaclass,Crawl 类将它设置为元类,元类中实现了 new() 方法,这个方法有固定的几个参数,其中第四个参数 attrs 中包含了类的一些属性,这其中就包含了类中方法的一些信息,我们可以遍历 attrs 这个变量即可获取类的所有方法信息。所以在这里我们在 new() 方法中遍历了 attrs 的这个属性,就像遍历一个字典一样,键名对应的就是方法的名称,接下来判断其开头是否是 crawl,如果是,则将其加入到 CrawlFunc 属性中,这样我们就成功将所有以 crawl 开头的方法定义成了一个属性,就成功动态地获取到所有以 crawl 开头的方法列表了。
所以说,如果要做扩展的话,我们只需要添加一个以 crawl开头的方法,例如抓取快代理,我们只需要在 Crawler 类中增加 crawl_kuaidaili() 方法,仿照其他的几个方法将其定义成生成器,抓取其网站的代理,然后通过 yield 返回代理即可,所以这样我们可以非常方便地扩展,而不用关心类其他部分的实现逻辑。
代理网站的添加非常灵活,不仅可以添加免费代理,也可以添加付费代理,一些付费代理的提取方式其实也类似,也是通过 Web 的形式获取,然后进行解析,解析方式可能更加简单,如解析纯文本或 Json,解析之后以同样的方式返回即可,在此不再添加,可以自行扩展。
既然定义了这个 Crawler 类,我们就要调用啊,所以在这里再定义一个 Getter 类,动态地调用所有以 crawl 开头的方法,然后获取抓取到的代理,将其加入到数据库存储起来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
from db import RedisClient from crawler import Crawler
POOL_UPPER_THRESHOLD = 10000
class Getter(): 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) |
Getter 类就是获取器类,这其中定义了一个变量 POOL_UPPER_THRESHOLD 表示代理池的最大数量,这个数量可以灵活配置,然后定义了 is_over_threshold() 方法判断代理池是否已经达到了容量阈值,它就是调用了 RedisClient 的 count() 方法获取代理的数量,然后加以判断,如果数量达到阈值则返回 True,否则 False。如果不想加这个限制可以将此方法永久返回 True。
接下来定义了 run() 方法,首先判断了代理池是否达到阈值,然后在这里就调用了 Crawler 类的 CrawlFunc 属性,获取到所有以 crawl 开头的方法列表,依次通过 get_proxies() 方法调用,得到各个方法抓取到的代理,然后再利用 RedisClient 的 add() 方法加入数据库,这样获取模块的工作就完成了。
检测模块
在获取模块中,我们已经成功将各个网站的代理获取下来了,然后就需要一个检测模块来对所有的代理进行一轮轮的检测,检测可用就设置为 100,不可用就分数减 1,这样就可以实时改变每个代理的可用情况,在获取有效代理的时候只需要获取分数高的代理即可。
由于代理的数量非常多,为了提高代理的检测效率,我们在这里使用异步请求库 Aiohttp 来进行检测。
Requests 作为一个同步请求库,我们在发出一个请求之后需要等待网页加载完成之后才能继续执行程序。也就是这个过程会阻塞在等待响应这个过程,如果服务器响应非常慢,比如一个请求等待十几秒,那么我们使用 Requests 完成一个请求就会需要十几秒的时间,中间其实就是一个等待响应的过程,程序也不会继续往下执行,而这十几秒的时间其实完全可以去做其他的事情,比如调度其他的请求或者进行网页解析等等。
异步请求库就解决了这个问题,它类似 JavaScript 中的回调,意思是说在请求发出之后,程序可以继续接下去执行去做其他的事情,当响应到达时,会通知程序再去处理这个响应,这样程序就没有被阻塞,充分把时间和资源利用起来,大大提高效率。
对于响应速度比较快的网站,可能 Requests 同步请求和 Aiohttp 异步请求的效果差距没那么大,可对于检测代理这种事情,一般是需要十多秒甚至几十秒的时间,这时候使用 Aiohttp 异步请求库的优势就大大体现出来了,效率可能会提高几十倍不止。
所以在这里我们的代理检测使用异步请求库 Aiohttp,实现示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
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): """ 测试单个代理 :param proxy: 单个代理 :return: None """ conn = aiohttp.TCPConnector(verify_ssl=False) async with aiohttp.ClientSession(connector=conn) as 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 (ClientError, ClientConnectorError, TimeoutError, AttributeError): self.redis.decrease(proxy) print('代理请求失败', proxy)
def run(self): """ 测试主函数 :return: None """ 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) |
在这里定义了一个类 Tester,init() 方法中建立了一个 RedisClient 对象,供类中其他方法使用。接下来定义了一个 test_single_proxy() 方法,用来检测单个代理的可用情况,其参数就是被检测的代理,注意这个方法前面加了 async 关键词,代表这个方法是异步的,方法内部首先创建了 Aiohttp 的 ClientSession 对象,此对象类似于 Requests 的 Session 对象,可以直接调用该对象的 get() 方法来访问页面,在这里代理的设置方式是通过 proxy 参数传递给 get() 方法,请求方法前面也需要加上 async 关键词标明是异步请求,这也是 Aiohttp 使用时的常见写法。
测试的链接在这里定义常量为 TEST_URL,如果针对某个网站有抓取需求,建议将 TEST_URL 设置为目标网站的地址,因为在抓取的过程中,可能代理本身是可用的,但是该代理的 IP 已经被目标网站封掉了。例如,如要抓取知乎,可能其中某些代理是可以正常使用,比如访问百度等页面是完全没有问题的,但是可能对知乎来说可能就被封了,所以可以将 TEST_URL 设置为知乎的某个页面的链接,当请求失败时,当代理被封时,分数自然会减下来,就不会被取到了。
如果想做一个通用的代理池,则不需要专门设置 TEST_URL,可以设置为一个不会封 IP 的网站,也可以设置为百度这类响应稳定的网站。
另外我们还定义了 VALID_STATUS_CODES 变量,是一个列表形式,包含了正常的状态码,如可以定义成 [200],当然对于某些检测目标网站可能会出现其他的状态码也是正常的,可以自行配置。
获取 Response 后需要判断响应的状态,如果状态码在 VALID_STATUS_CODES 这个列表里,则代表代理可用,调用 RedisClient 的 max() 方法将代理分数设为 100,否则调用 decrease() 方法将代理分数减 1,如果出现异常也同样将代理分数减 1。
另外在测试的时候设置了批量测试的最大值 BATCH_TEST_SIZE 为 100,也就是一批测试最多测试 100个,这可以避免当代理池过大时全部测试导致内存开销过大的问题。
随后在 run() 方法里面获取了所有的代理列表,使用 Aiohttp 分配任务,启动运行,这样就可以进行异步检测了,写法可以参考 Aiohttp 的官方示例:http://aiohttp.readthedocs.io/。
这样测试模块的逻辑就完成了。
接口模块
通过上述三个模块我们已经可以做到代理的获取、检测和更新了,数据库中就会以有序集合的形式存储各个代理还有对应的分数,分数 100 代表可用,分数越小代表越不可用。
但是我们怎样来方便地获取可用代理呢?用 RedisClient 类来直接连接 Redis 然后调用 random() 方法获取当然没问题,这样做效率很高,但是有这么几个弊端:
综上考虑,为了使得代理池可以作为一个独立服务运行,我们最好增加一个接口模块,以 Web API 的形式暴露可用代理。
这样获取代理只需要请求一下接口即可,以上的几个缺点弊端可以解决。
我们在这里使用一个比较轻量级的库 Flask 来实现这个接口模块,实现示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
from flask import Flask, g from db import RedisClient
__all__ = ['app'] app = Flask(__name__)
def get_conn(): if not hasattr(g, 'redis'): g.redis = RedisClient() return g.redis
@app.route('/') def index(): return ' Welcome to Proxy Pool System'
@app.route('/random') def get_proxy(): """ 获取随机可用代理 :return: 随机代理 """ conn = get_conn() return conn.random()
@app.route('/count') def get_counts(): """ 获取代理池总量 :return: 代理池总量 """ conn = get_conn() return str(conn.count())
if __name__ == '__main__': app.run() |
在这里我们声明了一个 Flask 对象,定义了三个接口,分别是首页、随机代理页、获取数量页。
运行之后 Flask 会启动一个 Web 服务,我们只需要访问对应的接口即可获取到可用代理。
调度模块
这个模块其实就是调用以上所定义的三个模块,将以上三个模块通过多进程的形式运行起来,示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
TESTER_CYCLE = 20 GETTER_CYCLE = 20 TESTER_ENABLED = True GETTER_ENABLED = True API_ENABLED = True
from multiprocessing import Process from api import app from getter import Getter from tester import Tester
class Scheduler(): def schedule_tester(self, cycle=TESTER_CYCLE): """ 定时测试代理 """ tester = Tester() while True: print('测试器开始运行') tester.run() time.sleep(cycle)
def schedule_getter(self, cycle=GETTER_CYCLE): """ 定时获取代理 """ getter = Getter() while True: print('开始抓取代理') getter.run() time.sleep(cycle)
def schedule_api(self): """ 开启API """ app.run(API_HOST, API_PORT)
def run(self): print('代理池开始运行') if TESTER_ENABLED: tester_process = Process(target=self.schedule_tester) tester_process.start()
if GETTER_ENABLED: getter_process = Process(target=self.schedule_getter) getter_process.start()
if API_ENABLED: api_process = Process(target=self.schedule_api) api_process.start() |
在这里还有三个常量,TESTER_ENABLED、GETTER_ENABLED、API_ENABLED 都是布尔类型,True 或者 False。标明了测试模块、获取模块、接口模块的开关,如果为 True,则代表模块开启。
启动入口是 run() 方法,其分别判断了三个模块的开关,如果开启的话,就新建一个 Process 进程,设置好启动目标,然后调用 start() 方法运行,这样三个进程就可以并行执行,互不干扰。
三个调度方法结构也非常清晰,比如 schedule_tester() 方法,这是用来调度测试模块的方法,首先声明一个 Tester 对象,然后进入死循环不断循环调用其 run() 方法,执行完一轮之后就休眠一段时间,休眠结束之后重新再执行。在这里休眠时间也定义为一个常量,如 20 秒,这样就会每隔 20 秒进行一次代理检测。
最后整个代理池的运行只需要调用 Scheduler 的 run() 方法即可启动。
以上便是整个代理池的架构和相应实现逻辑。
接下来我们将代码整合一下,将代理运行起来,运行之后的输出结果如图 9-2 所示:
图 9-2 运行结果
以上是代理池的控制台输出,可以看到可用代理设置为 100,不可用代理分数减 1。
接下来我们再打开浏览器,当前配置了运行在 5555 端口,所以打开:http://127.0.0.1:5555,即可看到其首页,如图 9-3 所示:
图 9-3 首页页面
再访问:http://127.0.0.1:5555/random,即可获取随机可用代理,如图 9-4 所示:
图 9-4 获取代理页面
所以后面我们只需要访问此接口即可获取一个随机可用代理,非常方便。
获取代理的代码如下:
1 2 3 4 5 6 7 8 9 10 11 |
import requests
PROXY_POOL_URL = 'http://localhost:5555/random'
def get_proxy(): try: response = requests.get(PROXY_POOL_URL) if response.status_code == 200: return response.text except ConnectionError: return None |
获取下来之后便是一个字符串类型的代理,可以按照上一节所示的方法设置代理,如 Requests 的使用方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
import requests
proxy = get_proxy() proxies = { 'http': 'http://' + proxy, 'https': 'https://' + proxy, } try: response = requests.get('http://httpbin.org/get', proxies=proxies) print(response.text) except requests.exceptions.ConnectionError as e: print('Error', e.args) |
有了代理池之后,我们再取出代理即可有效防止IP被封禁的情况。
本节代码地址为:https://github.com/Python3WebSpider/ProxyPool。
本节我们实现了一个比较高效的代理池来获取随机可用的代理,整个内容比较多,需要好好理解一下。
在后文我们会利用代理池来实现数据的抓取。