IP 代理池的搭建(笔记)

前言

去年实习的时候做过爬虫,但是理解的很浅,所以今年趁着在家的时候,又打算认真地学习一下。刚好看到崔庆才老师拉勾网的教程 52讲轻松搞定网络爬虫,于是就跟着学了一下。到代理池这时,由于一些基础东西没整明白,耽误了好一阵,好在整个项目代码量不大。断断续续的,终于在今天对着 github 敲完了一遍,有的地方做了一下小改动。在这里记录一下搭建过程和最后结果。

具体参考崔老师的博客:[Python3网络爬虫开发实战] 9.2-代理池的维护 和项目地址:ProxyPool

代理池介绍

爬虫的反爬方法有很多,最简单的就是更改 headers。不过有当用同一 IP 爬取一个网站过于频繁的时候,这个 IP 很容易会被网站封掉。所以通常,为了爬取网站时自己的 IP 被封,都会使用代理。而且为了爬虫运行正常,一般会经常更换代理。使用代理的优势不止防止被封这一点,有时候也可以达到突破访问限制,访问一些正常访问不了的网站。

那么如何获取 IP 呢?其实网上有很多免费的代理网站,不过里面的 IP 地址大多是不好使的,不过付费的很多时候又没有必要。这时候就需要代理池了。代理池的作用是爬取很多 IP 代理,保存起来,然后验证代理是否可用,当需要使用的时候,就可以从其中取出可用的代理。是不是感觉很酷?

从功能上看,代理池的基本架构就分为四部分:

  • 获取模块(Getter):从各个免费的代理网站上爬取 IP 代理。

  • 存储模块(Store): 将爬取的 IP 代理存起来,当需要使用时,从中取出有效的代理。这里如何存取更新是关键,使用 redis 中的有序集合(Sorted Set)进行存储。给每个代理设置一个分数,当刚存入数据库时,初始分数设为 10。测试更新时,如果检测到代理可用,则设成最大值 100;否则,如果检测到不可用,则每次减一,直到减到最小,删除该代理。

  • 测试模块(Tester): 测试是通过使用代理访问一个特定网站,看访问是否成功。每次测试,都会更新一下代理的分数。

  • 接口模块(Server): 由于是存放的数据库中的,如果每次取用都要暴露数据库信息,不太方便,也不安全。因此,这里使用 flask 做了一个简单的后台服务端。

下面是代理池的框架流程(盗图):

IP 代理池架构

这就是代理池的基本思路,代码也不多,项目的可扩展性比较好,下面具体介绍一下每个模块。

代理池代码解释

获取模块

获取模块是爬取各大免费 IP 代理网站,获取到 IP。免费网站主要有: 西刺免费代理快代理66免费代理等等。有一点比较好的是,这些网站结构比较简单,代理比较好爬,如果不考虑反爬措施的话,还是很亲民的。

代理池中,所有的获取代理代码都继承自 BaseCrawler:

# -*- coding:utf-8  -*-
from retrying import retry
import requests
from loguru import logger
import random
class BaseCrawler(object):
    USER_AGENT_LIST = [
        'MSIE (MSIE 6.0; X11; Linux; i686) Opera 7.23',
        'Opera/9.20 (Macintosh; Intel Mac OS X; U; en)',
        'Opera/9.0 (Macintosh; PPC Mac OS X; U; en)',
        'iTunes/9.0.3 (Macintosh; U; Intel Mac OS X 10_6_2; en-ca)',
        'Mozilla/4.76 [en_jp] (X11; U; SunOS 5.8 sun4u)',
        'iTunes/4.2 (Macintosh; U; PPC Mac OS X 10.2)',
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:5.0) Gecko/20100101 Firefox/5.0',
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:9.0) Gecko/20100101 Firefox/9.0',
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:16.0) Gecko/20120813 Firefox/16.0',
        'Mozilla/4.77 [en] (X11; I; IRIX;64 6.5 IP30)',
        'Mozilla/4.8 [en] (X11; U; SunOS; 5.7 sun4u)',
        'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/67.0.3396.62 Safari/537.36'
    ]
    urls = []  # 网站的列表页(即包含 IP 的网页)
    # 定义重试次数
    @retry(stop_max_attempt_number=3, retry_on_result=lambda x: x is None)
    def fetch(self, url, **kwargs):
        ''' 爬取 url '''
        headers = {
            "Host": "www.xicidaili.com",
            "User-Agent": random.choice(self.USER_AGENT_LIST),
        }
        try:
            response = requests.get(url, headers=headers, **kwargs)
            if response.status_code == requests.codes.ok:
                return response.text
        except requests.ConnectionError:
            return
    @logger.catch
    def crawl(self):
        ''' 爬取网站的主函数 '''
        for url in self.urls:
            logger.info(f'fetching {url}')
            html = self.fetch(url)
            for proxy in self.parse(html):
                logger.info(f'fetched proxy {proxy.string()} from {url}')
                yield proxy

这里增加了一些爬取时的头部信息。

所有爬取网站的代码,都可以通过继承它来放到项目中,扩展性很好,我尝试写了个西刺代理网站的代码:

# -*- coding:utf-8  -*-
from proxypool.crawlers.base import BaseCrawler
from proxypool.schemas.proxy import Proxy
from pyquery import PyQuery as pq
BASE_URL = 'https://www.xicidaili.com/nn/{page}'
MAX_PAGE = 200
class XicidailiCrawler(BaseCrawler):
    '''
    xicidaili crawler, https://www.xicidaili.com/nn/
    '''
    urls = [BASE_URL.format(page=page) for page in range(1, MAX_PAGE + 1)]

    def parse(self, html):
        '''parse html file to get proxies
        :param html: the text of website
        :type html: str

        :return: the generator of proxies
        :rtype: Proxy
        '''
        doc = pq(html)
        for tr in doc('#ip_list tr:gt(0)').items():
            host = tr.find('td:nth-child(2)').text()
            port = tr.find('td:nth-child(3)').text()
            if host and port:
                proxy = Proxy(host=host, port=port)
                yield proxy
if __name__ == '__main__':
    crawler = XicidailiCrawler()
    for proxy in crawler.crawl():
        print(proxy)

代码可以运行,不过西刺代理有一些反爬机制,不能太过频繁(我就是太快了,导致它把我的 IP 给封掉了),所以并没有放到最后的项目中。

存储模块

存储时,使用的是 Redis 数据库。由于之前没使用过,现学了一下,感觉这个库有点酷啊。跟 MongoDB 一样,是 key-value 类型的数据库,不过 Redis 支持五种数据类型,这里用的是 Sorted Set 有序集合类型。

需要注意的是,使用前要安装好 Redis。存储的代码如下:

# -*- coding:utf-8  -*-
import redis
from proxypool.exceptions.empty import PoolEmptyException
from proxypool.schemas.proxy import Proxy
from proxypool.setting import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD,\
     REDIS_KEY, PROXY_SCORE_MAX, PROXY_SCORE_MIN, PROXY_SCORE_INIT
from random import choice
from typing import List
from loguru import logger
from proxypool.utils.proxy import is_valid_proxy, convert_proxy_or_proxies

REDIS_CLIENT_VERSION = redis.__version__
IS_REDIS_VERSION_2 = REDIS_CLIENT_VERSION.startswith('2.')


class RedisClient(object):
    '''
    代理池的 redis 连接
    '''
    def __init__(self,
                 host=REDIS_HOST,
                 port=REDIS_PORT,
                 password=REDIS_PASSWORD,
                 **kwargs):
        '''
        初始化 redis 客户端
        :param host: redis host
        :param port: redis port
        :param password: redis password
        '''
        self.db = redis.StrictRedis(host=host,
                                    port=port,
                                    password=password,
                                    decode_responses=True,
                                    **kwargs)

    def add(self, proxy: Proxy, score=PROXY_SCORE_INIT) -> int:
        '''
        将代理加到 redis 中,并设置初始分数
        :param proxy: 代理,格式 ip:port, 例如 8.8.8.8:888
        :param score: 代理初始化的分数
        :type score: int
        :return: 成功添加的数量
        '''
        if not is_valid_proxy(f'{proxy.host}:{proxy.port}'):
            logger.info(f'invalid proxy {proxy}, throw it')
            return
        # if not self.db.exists(proxy):
        # 将代理添加到有序集合中
        if IS_REDIS_VERSION_2:
            return self.db.zadd(REDIS_KEY, score, proxy.string())
        return self.db.zadd(REDIS_KEY, {proxy.string(): score})

    def random(self) -> Proxy:
        '''
        获取随机代理
        1. 最开始,尝试获取最大分数的代理
        2. 如果不存在, 尝试通过排行榜获取
        3. 如果还是不存在,报错
        :return: proxy, like 8.8.8.8:888
        '''
        # 1. 尝试获取最大分数的代理
        proxies = self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MAX,
                                        PROXY_SCORE_MAX)
        if len(proxies):
            return convert_proxy_or_proxies(choice(proxies))
        # 没有的话,通过排名获取
        proxies = self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MIN,
                                        PROXY_SCORE_MAX)
        if len(proxies):
            return convert_proxy_or_proxies(choice(proxies))
        # 如果都没有, 报错
        raise PoolEmptyException

    def decrease(self, proxy: Proxy) -> int:
        '''
        降低代理的分数,如果比最小值还低,则删除
        :param proxy: proxy
        :return: new score
        '''
        score = self.db.zscore(REDIS_KEY, proxy.string())
        # 当前分数比最小值大
        if score and score > PROXY_SCORE_MIN:
            logger.info(f'{proxy.string()} current score {score}, decrease 1')
            if IS_REDIS_VERSION_2:
                return self.db.zincrby(REDIS_KEY, proxy.string(), -1)
            return self.db.zincrby(REDIS_KEY, -1, proxy.string())
        # 当前分数比最小值小
        else:
            logger.info(f'{proxy.string()} current score {score}, remove')
            return self.db.zrem(REDIS_KEY, proxy.string())

    def max(self, proxy: Proxy) -> int:
        '''
        将代理分数设成最大
        :param proxy: 代理
        :return: 新的分数
        '''
        logger.info(f'{proxy.string()} is valid, set to {PROXY_SCORE_MAX}')
        if IS_REDIS_VERSION_2:
            return self.db.zadd(REDIS_KEY, PROXY_SCORE_MAX, proxy.string())
        return self.db.zadd(REDIS_KEY, {proxy.string(): PROXY_SCORE_MAX})

    def count(self) -> int:
        '''
        获取代理的数量
        :return: 数量
        :rtype: int
        '''
        return self.db.zcard(REDIS_KEY)

    def all(self) -> List[Proxy]:
        '''
        获取所有代理
        :return: 代理列表
        '''
        return convert_proxy_or_proxies(
            self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MIN, PROXY_SCORE_MAX))

    def batch(self, start, end) -> List[Proxy]:
        '''
        获取一批特定区间中的代理
        :param start: 代理的起始索引
        :param end: 代理的结束索引
        :return: 代理列表
        '''
        return convert_proxy_or_proxies(
            self.db.zrevrange(REDIS_KEY, start, end - 1))


if __name__ == '__main__':
    conn = RedisClient()
    conn.add(Proxy(host='8.8.8.8', port='888'))
    result = conn.random()
    print(result)

这里主要是记录了一下数据库的增删改查。增加代理时设置初始分数;更改代理时,设置分数减一或是变为最大值;删除时

测试模块

测试时,需要使用一个测试网站,用代理去访问它。通常是要爬取哪个网站就用哪个网站进行测试,不过通用的话,使用的是百度,毕竟比较稳定。

测试时,根据代理是否可用,设置数据库中的代理分数。如果可用,直接设成最大值;不可用,分数减一。当分数减到比最小值还小时,删除该代理。

测试代码如下:

# -*- coding:utf-8  -*-
import asyncio
import aiohttp
from loguru import logger
from proxypool.schemas.proxy import Proxy
from proxypool.storages.store_by_redis import RedisClient
from proxypool.setting import TEST_TIMEOUT, TEST_BATCH, TEST_URL, TEST_VALID_STATUS
from aiohttp import ClientProxyConnectionError, ServerDisconnectedError, ClientOSError, ClientHttpProxyError
from asyncio import TimeoutError

EXCEPTIONS = (
    ClientProxyConnectionError, 
    ConnectionRefusedError,
    TimeoutError,
    ServerDisconnectedError,
    ClientOSError,
    ClientHttpProxyError
)


class Tester(object):
    ''' 代理池的 tester, 用于测试在队列中的代理 '''
    def __init__(self):
        ''' 初始化 redis '''
        self.redis = RedisClient()
        self.loop = asyncio.get_event_loop()

    async def test(self, proxy: Proxy):
        ''' 测试一个代理 
        :param proxy: Proxy object
        :return:
        '''
        async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False)) as session:
            try:
                logger.debug(f'testing {proxy.string()}')
                async with session.get(TEST_URL, proxy=f'http://{proxy.string()}', timeout=TEST_TIMEOUT,
                                       allow_redirects=False) as response:
                    if response.status in TEST_VALID_STATUS:
                        self.redis.max(proxy)
                        logger.debug(f'proxy {proxy.string()} is valid, set max score')
                    else:
                        self.redis.decrease(proxy)
                        logger.debug(f'proxy {proxy.string()} is invalid, decrease score')
            except EXCEPTIONS:
                self.redis.decrease(proxy)
                logger.debug(f'proxy {proxy.string()} is invalid, decrease score')

    @logger.catch
    def run(self):
        ''' 测试主函数 '''
        logger.info('stating tester...')
        count = self.redis.count()
        logger.debug(f'{count} proxies to test')
        for i in range(0, count, TEST_BATCH):
            start, end = i, min(i + TEST_BATCH, count)
            logger.debug(f'testing proxies from {start} to {end} indices')
            proxies = self.redis.batch(start, end)
            tasks = [self.test(proxy) for proxy in proxies]
            self.loop.run_until_complete(asyncio.wait(tasks))


if __name__ == '__main__':
    tester = Tester()
    tester.run()

接口模块

接口模块不是必须的,其实前三个模块组合起来就已经实现了代理池的所有功能了。接口模块只是让项目更完善,将底层封装起来,项目更完整。

这里做接口使用的是 flask,我同样也没接触过,不过好在上手很容易,项目中也没用到多难的东西,很容易理解。直接上代码:

# -*- coding:utf-8  -*-
from flask import Flask, g
from proxypool.storages.store_by_redis import RedisClient
from proxypool.setting import API_HOST, API_PORT, API_THREADED
app = Flask(__name__)
def get_conn():
    ''' 获取 redis 客户端对象
   :return: redis 客户端
   '''
    if not hasattr(g, 'redis'):
        g.redis = RedisClient()
    return g.redis
@app.route('/')
def index():
    ''' 获取主界面,可以自定义自己的界面(这里就使用教程的默认界面了)
   :return:
   '''
    return '<h2>Welcome to Proxy Pool System —— by rocketeerli</h2>'
@app.route('/random')
def get_proxy():
    ''' 获取一个随机代理
   :return: 一个随机代理
   '''
    conn = get_conn()
    return conn.random().string()
@app.route('/count')
def get_count():
    ''' 获取代理的数目
   :return: 代理数目
   :rtype: int
   '''
    conn = get_conn()
    return str(conn.count())
if __name__ == '__main__':
    app.run(host=API_HOST, port=API_PORT, threaded=API_THREADED)

接口模块的测试结果如下:
代理池接口模块的测试结果

配置文件

这里有许多的配置项,在 setting.py 文件中,截取一部分如下:

# 代理分数
PROXY_SCORE_MAX = 100
PROXY_SCORE_MIN = 0
PROXY_SCORE_INIT = 10
# 代理数目
PROXY_NUMBER_MAX = 50000
PROXY_NUMBER_MIN = 0

# 测试代理的循环时间
CYCLE_TESTER = env.int('CYCLE_TESTER', 20)
# 获取代理的循环时间
CYCLE_GETTER = env.int('CYCLE_GETTER', 100)

# 测试的信息
TEST_URL = env.str('TEST_URL', 'http://www.baidu.com')
TEST_TIMEOUT = env.int('TEST_TIMEOUT', 10)  # 测试的超时时间,默认 10 秒
TEST_BATCH = env.int('TEST_BATCH', 20)  # 异步测试时,每次的 task 数目
TEST_VALID_STATUS = env.list('TEST_VALID_STATUS', [200, 206, 302])  # 测试网站返回的正确状态码

可以自行根据自己的需求进行更改。

最后

最后,附上我的练习代码地址,在原代码基础上稍微做了点小更改。

虽然用的时间比较长,但不得不说,这个项目还是比较好的。代码量虽然不多,但确实让我学到了很多东西,对 python 语言也有了一个更深的认识。最重要的是,对新手很友好。不多bb,强烈推荐崔大的教程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值