Loading

Python 自建 IP 代理池

文章有点儿长,谨慎食用~

python 爬虫在爬取网页内容时,遭遇的最常见的反爬措施就是 ip 限制/封禁,对此最常见的解决方式就是设置 IP 代理池,每次请求时随机使用一个代理 IP 去访问资源。

网上有成熟的代理服务,但是小伙汁的爬虫需求多是非定期的自定义项目,使用付费代理并不划算,遂有了爬取免费代理并测试是否可用,进而构建一个可用代理 IP 池的想法。本项目亦可作为后续网络相关服务的子模块。

版本1:先通过 request 或者 selenium 进行爬取;
版本2(大概率是鸽了~):学习并使用 scrapy 进行爬取,填上坑了,重构了在这儿 https://www.cnblogs.com/zishu/p/17516900.html

0 项目逻辑架构

经过在编码过程中不断的修修改改,重重构构,整体逻辑终于是有了一个相对解耦的模式,由于项目相比于大型项目来说还是 just like a toy,所以核心模块就是一个通用的 Spider 父类模块,定义了整体的爬虫逻辑,其余针对特定网页的实例都继承自该 Spider。先看图吧:

  • Spider 是基础爬虫类,定义了一些静态的属性和功能方法
  • spider x 是实例爬虫,每个实例爬虫需要根据自己网页的结构需要,重写 pre_parse()parse()get_all_proxies() 方法
  • Proxy Manager 是运行时的代理管理类(目前仅简单提供可用代理的临时存储功能)
  • 项目运行前,将多个对象爬虫实例化后配置在配置文件中,组成爬虫链。这样主函数执行时会串行加载每个爬虫实例并运行其爬取逻辑(之所以不用并行是想着后面的爬虫能利用前面爬虫验证过的代理,所以将那些没有反爬的爬虫实例尽量配在爬虫链的前面)

下面会详细的介绍每个模块的具体实现细节,再介绍之前,让我再 bb 几句吧。这个项目主要是出于个人兴趣,作为一个初入 python 爬虫领域的菜鸟,利用业余时间在拖拖拉拉中写完了这个项目,写的过程中也逐渐学习了一些 python 的高级语法,例如装饰器、自定义异常、多进程异步操作等,收获还是蛮多的。此外,由于免费代理资源本身并不是很稳定,指望通过免费代理资源来构建一个鲁棒的代理池还是有点困难的,所以这个项目更多的还是当学习使用。

食用提醒

  1. 前置知识
    • 最好还是要对 python requests 库有点了解,包括 请求、jsonpath 解析网页资源 等
    • python 类的继承、多态等
  2. 可能的收获
    • python requests 的使用
    • python 类的使用
    • python 多进程、进程池
    • python 装饰器
    • python 自定义异常类并在程序中手动抛出并处理
    • python 进度条、输出格式个性化定制等
    • 一点点软件工程设计的思想

1 Spider 模块

这个模块一开始写得时候比较简单,但是在后续加入各种各样的实例爬虫后,为了解耦和鲁棒性,功能也在不断的完善,为了阅读方便,先上简单版本代码:

# _*_ coding : utf-8 _*_
"""
定义基础 Spider 类
"""
import sys
import time
import requests
from tqdm import tqdm
from tools import check_proxy_icanhazip, check_proxy_900cha, get_free_proxy
from concurrent.futures import ProcessPoolExecutor, as_completed
import json
from config import *


class Spider:
    def __init__(self, *args, **kwargs):
        self.url = kwargs.get('url')
        self.headers = kwargs.get('headers')
        self.req_type = kwargs.get('req_type')		# get or post
        self.data = kwargs.get('data')				# for post
        self.proxies = kwargs.get('proxies')
        self.verify = kwargs.get('verify')
        self.day = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())).split(' ')[0].strip()	# time tag
        self.timeout = 3

        self.proxy_try_num = 0      	# 设置使用代理时的全局失败尝试次数
        self.response = None
        self.parse_urls = []			# 代理资源页入口
        self.all_proxies = []			# 爬取到的所有代理
        self.all_proxies_filter = []	# 验证后可用的所有代理

    def pre_parse(self):
        """
        识别当天代理信息资源页面
        """
        pass

    def parse(self):
        """
        解析代理
        """
        pass

    def get_all_proxies(self):
        """
        获取所有 proxies
        """
        pass

    def update_attrs(self, *args, **kwargs):
        """
        update the spider object's attrs
        :param args:
        :param kwargs:
        :return:
        """
        self.url = self.url if kwargs.get('url') is None else kwargs.get('url')
        self.headers = self.headers if kwargs.get('headers') is None else kwargs.get('headers')
        self.req_type = self.req_type if kwargs.get('req_type') is None else kwargs.get('req_type')
        self.data = self.data if kwargs.get('data') is None else kwargs.get('data')
        self.proxies = self.proxies if kwargs.get('proxies') is None else kwargs.get('proxies')
        self.verify = self.verify if kwargs.get('verify') is None else kwargs.get('verify')
        self.timeout = self.timeout if kwargs.get('timeout') is None else kwargs.get('timeout')

    def update_response(self, *args, **kwargs):
        """
        recontruct a url request, and update the spider's response attribute
        :param args:
        :param kwargs:
        :return:
        """
	if self.data is None:
	    self.response = requests.get(self.url, headers=self.headers, timeout=self.timeout, proxies=self.proxies, verify=self.verify)
	else:
	    self.response = requests.post(self.url, headers=self.headers, data=self.data, timeout=self.timeout, proxies=self.proxies, verify=self.verify)

        return self.response

    def filter_all_proxies_mp(self):
        """
        测试代理 ip 可用性
        多进程处理
        """
        self.all_proxies_filter = dict()

        # 进程池
        pool = ProcessPoolExecutor(max_workers=50)
        all_task = [pool.submit(check_proxy_900cha, proxy) for proxy in self.all_proxies]
        for future in tqdm(as_completed(all_task), total=len(all_task), file=sys.stdout, desc='[{}] checking proxies...'.format(self.__class__.__name__)):
            res, proxy = future.result()
            if res:
                self.all_proxies_filter['{}:{}'.format(proxy['ip'], proxy['port'])] = proxy

        self.all_proxies_filter = self.all_proxies_filter.values()
        return self.all_proxies_filter

    def save_to_txt(self, file_name, all_proxies, add_day_tag=True):
        """
        存文件
        """
        if not os.path.isdir(os.path.dirname(file_name)):
            os.makedirs(os.path.dirname(file_name))
        if add_day_tag:
            file_name = file_name.split('.')[0] + '_{}.'.format(self.day.replace('-', '_')) + file_name.split('.')[-1]
        with open(file_name, 'a+', encoding='utf-8') as f:
            for proxy in all_proxies:
                f.write(json.dumps(proxy, ensure_ascii=False) + '\n')

    def run(self):
        """
        General spider running logic:
            init -> face page url request -> (resource page collect) -> crawl all proxies -> check proxies' useability -> save
        :return:
        """
        # 1 爬取所有 proxies
        self.all_proxies = self.get_all_proxies()
        print('[{}] 爬取代理数:{}'.format(self.__class__.__name__, len(self.all_proxies)))
        # 2 过滤可用代理
        self.all_proxies_filter = self.filter_all_proxies_mp()
        print('[{}] 可用代理数:{}'.format(self.__class__.__name__, len(self.all_proxies_filter)))
        # 3 存储可用代理
        # 默认存储路径配置在 config 中,如果想要另存,在子爬虫中重构 run() 方法即可
        self.save_to_txt(os.path.join(useful_ip_file_path, useful_ip_file_name), self.all_proxies_filter)

        print('[{}] run successed.'.format(self.__class__.__name__))

        return list(self.all_proxies_filter)


if __name__ == '__main__':
    help(Spider)

上面这段代码即对应着项目中所有实例爬虫的通用运行逻辑,步骤如下:

  1. 初始化相关参数
  2. run() 实例化爬虫运行入口
  3. pre_parse() 请求代理资源页
  4. get_all_proxies() 爬取逻辑的入口函数,开始对当前实例爬虫进行代理资源爬取
  5. parse() 对代理详情页进行解析
  6. filter_all_proxies_mp() 验证爬取到的代理的可用性
  7. save_to_txt() 将可用代理存到文件中

为了便于理解代理资源页、代理详情页,下面以站大爷的网页结构为例进行说明:

代理资源页:可包含多个具体的代理资源集合

代理详情页:即前面每一条资源的详情页面

实际使用中,假设我们已有了一个实例爬虫 SpiderX,只需通过以下方式来启动:

spider_obj = SpiderX()
spider_obj.run()

插播一下:
上面我这里直接上了进程池的版本,本来一开始写得是串行验证,但是速度太慢了,所以果断换多进程并发。如果对 python 多进程不太熟悉,可以先停一会儿去这里看下相关知识,啪的一下很快的:https://www.cnblogs.com/zishu/p/17300868.html

1.1 Config

存储路径等参数放在 config.py 中:

# _*_ coding : utf-8 _*_

import os

RETRY_LIMIT = 4     # 爬取失败时的重试次数

# 存储文件地址
useful_ip_file_path = os.path.join('D:/FreeIPProxyGettingPro', 'proxies_spider_results')
# 存储文件名称
useful_ip_file_name = 'useful_proxies_spiding.txt'

1.2 验证代理可用性

当爬取到代理时,需要验证其可用性,对可用代理才将其保存。一般来说,较为简单的验证逻辑就是使用该代理对百度首页进行访问,然后根据返回结果验证代理可用性。但是在实际使用过程中,会出现各种问题,比如代理访问并没有隐藏掉源 ip、百度返回验证页面、无法访问但返回一个正常的说明网页(非百度首页)等。

所以这里采用的是使用代理对 IP 查询网站(https://ip.900cha.com/)进行访问,解析网页结果,判断网页显示的 ip 是否与所使用的代理 ip 一致:

代码逻辑 tools.py

# _*_ coding : utf-8 _*_

import requests
from lxml import etree
import time

def check_proxy_900cha(proxy, timeout=3, realtimeout=False):
    """
    验证代理可用性
    :param proxy:
    :param timeout:
    :param realtimeout: 
    :return:
    """
	time.sleep(1)	# 防止频繁访问给服务器带来过大压力
    url = 'https://ip.900cha.com/'
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
    }
    proxies = {
        'http': '{}:{}'.format(proxy['ip'], proxy['port']),
        'https': '{}:{}'.format(proxy['ip'], proxy['port'])
    }
    try:
        response = requests.get(url=url, headers=headers, proxies=proxies, timeout=timeout)
    except Exception as e:
        return False, None
    else:
        tree = etree.HTML(response.text)
        ret_ip = tree.xpath('//div[@class="col-md-8"]/h3/text()')[0].strip()
        if ret_ip == proxies['http'].split(':')[0]:
            if realtimeout:
                print(f'代理 {proxy["ip"]}:{proxy["port"]} 有效!')
            return True, proxy
        else:
            return False, None

2 实例爬虫

搜集网上的一些免费代理资源,限于篇幅,这里以 3 个结构典型案例来展示。

2.1 seo 代理

https://proxy.seofangfa.com/

可以说是最简单的一个代理页面了,入口页直接就放了 proxy 列表:

实例代码:

# _*_ coding : utf-8 _*_

from tools import *
from ProxiesSpider.spider import Spider			# 前面的 Spider 类

class SpiderSeo(Spider):

    def __init__(self, *args, **kwargs):

        url = 'https://proxy.seofangfa.com/'
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
        }

        super().__init__(url=url, headers=headers)

    def pre_parse(self):
        self.parse_urls = [
            'https://proxy.seofangfa.com/'
        ]

    def parse(self):
        """
        解析代理
        """
        content = self.response.text
        tree = etree.HTML(content)
        proxies_obj = tree.xpath('//table[@class="table"]/tbody/tr')
        proxies = []
        for proxy_obj in proxies_obj:
            dic_ = {
                'ip': proxy_obj.xpath('./td[1]/text()')[0].strip(),
                'port': proxy_obj.xpath('./td[2]/text()')[0].strip(),
                'position': proxy_obj.xpath('./td[4]/text()')[0].strip(),
                'day': proxy_obj.xpath('./td[5]/text()')[0].strip().split(' ')[0],
            }
            proxies.append(dic_)
        return proxies

    def get_all_proxies(self):
        """
        获取所有 proxies
        """
        self.pre_parse()

        for parse_url in self.parse_urls:
            self.update_attrs(url=parse_url)
            self.update_response()

            proxies = self.parse()
            self.all_proxies += proxies

        return self.all_proxies


if __name__ == '__main__':

    spider_seo = SpiderSeo()
    spider_seo.run()

pre_parse() 用来获取代理资源页,但是 seo 没有,为了结构一致性,在其中直接填充详情页。

2.2 快代理

https://www.kuaidaili.com/free/inha/1/

快代理的网页结构介于 seo 和站大爷之间,其也没有代理资源页,但是其有两份代理资源(普通 & 高匿),所以同 seo 一样,直接在 pre_parse() 函数中填充即可。

# _*_ coding : utf-8 _*_

from ProxiesSpider.spider import Spider
from tools import *
import time
import sys

class SpiderKuai(Spider):

    def __init__(self, *args, **kwargs):

        kwargs['url'] = 'https://www.kuaidaili.com/free/inha/1/'
        kwargs['headers'] = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
        }

        super().__init__(**kwargs)

    def pre_parse(self):
        self.parse_urls = [
            'https://www.kuaidaili.com/free/intr/',     # 国内普通代理
            'https://www.kuaidaili.com/free/inha/'      # 国内高匿代理
        ]
        return self.parse_urls

    def parse(self):
        """
        解析代理
        """
        content = self.response.text
        tree = etree.HTML(content)
        proxies_obj = tree.xpath('//div[@id="list"]//tbody/tr')
        proxies = []
        for proxy_obj in proxies_obj:
            dic_ = {
                'ip': proxy_obj.xpath('./td[@data-title="IP"]/text()')[0].strip(),
                'port': proxy_obj.xpath('./td[@data-title="PORT"]/text()')[0].strip(),
                'type': proxy_obj.xpath('./td[@data-title="类型"]/text()')[0].strip(),
                'position': proxy_obj.xpath('./td[@data-title="位置"]/text()')[0].strip(),
                'day': proxy_obj.xpath('./td[@data-title="最后验证时间"]/text()')[0].strip().split(' ')[0]
            }
            if dic_['day'] != self.day:
                break
            proxies.append(dic_)
        return proxies

    def get_all_proxies(self):
        """
        获取所有 proxies
        """
        # 1 先获取所有待采集的 proxy list 页
        self.pre_parse()

        # 2 对每个 proxy 信息页的资源进行解析
        for parse_url in self.parse_urls:
            time.sleep(3)
            self.update_attrs(url=parse_url)
            self.update_response()

            # 3 获取资源页所有的 proxy
            count = 1
            pbar = tqdm(file=sys.stdout, desc='[{}] crawling all pages...'.format(self.__class__.__name__))
            while True:
                proxies = self.parse()
                if len(proxies) == 0:
                    break

                self.all_proxies += proxies

                next_page = '{}{}/'.format(parse_url, count+1)
                count += 1
                time.sleep(3)
                self.update_attrs(url=next_page)
                self.update_response()

                pbar.update(1)
            pbar.close()

        return self.all_proxies


if __name__ == '__main__':
    spider_kuai = SpiderKuai()
    spider_kuai.run()

基本结构和 seo 的逻辑一致,只是这里在代理详情页解析资源时要复杂些,因为是多页结构。
观察各页资源可以发现,快代理是将所有累计的免费代理都放在一起,并没有按天分区,所以这里要面对的问题有两个:

  • 翻页爬取
  • 翻页过程中对资源更新日期进行检测,一但资源声明周期超过当天,就停止继续爬取

所以我们这里直接采用一个 True 循环,在 parse() 中一旦遇到生成周期超过当天的资源后,就及时返回。这样在继续翻页并且下一页没有当天资源时,就会返回空列表,此时结束循环。

在测试过程中,我发现快代理的还是有着简单的反爬限制的:

  • 当连续访问多页内容时,会返回 -10,获取不到具体数据;
  • 一天内多次访问时,会封 IP;

其中针对第一种,只需要在多个连续请求之间 sleep() 一下即可。而对第二种限制,由于我们的爬虫正式逻辑是一天访问一次,所以正式运行时逻辑上不会存在封禁 ip 的情况,所以可以不做处理(当然后面也可以利用已获取的代理对快代理网站进行访问)。

插播一下

这里还有个知识点,就是 python 进度条组件 tqdm 的使用,由于 tqdm 默认的输出模式和 print 是不一样的,会导致 tqdm 输出和 print 输出排版交混的问题,所以这里需要在 tqdm 中指定 file=sys.stdout,这样就不会出现上述问题。

2.3 站大爷

相对来说结构最为完善的网页,包含代理资源页、代理详情页。所以 pre_parse() 函数中需要先对代理资源页进行解析,获取当天的代理详情页链接,再进二级页面进行爬取。

# _*_ coding : utf-8 _*_

from lxml import etree
from ProxiesSpider.spider import Spider
import time
import sys

class SpiderZdaye(Spider):
    """
    zdaye 自有证书,需要设置 verify=False
    """
    def __init__(self, *args, **kwargs):

        url = 'https://www.zdaye.com/dayProxy.html'
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
        }

        super().__init__(url=url, headers=headers, verify=False)

    def pre_parse(self):
        """
        代理资源页解析
        :return:
        """
        self.update_response()

	# 该网页需对 request 结果指定 utf-8 编码
        self.response.encoding = 'utf-8'
        content = self.response.text
        tree = etree.HTML(content)
        proxy_page_info_obj = tree.xpath('//div[@class="thread_content"]/h3/a')
        for ppio in proxy_page_info_obj:
            title = ppio.xpath('./text()')[0].strip().split(' ')[0]
            parse_day = title.split('日')[0].replace('年', '-').replace('月', '-')
            if [int(x) for x in parse_day.split('-')] == [int(x) for x in self.day.split('-')]:
                self.parse_urls.append(ppio.xpath('./@href')[0])
            else:
                break
        return self.parse_urls

    def parse(self):
        """
        解析代理
        """
        self.response.encoding = 'utf-8'
        content = self.response.text
        tree = etree.HTML(content)
        proxies_obj = tree.xpath('//table[@id="ipc"]/tbody/tr')
        proxies = []
        for proxy_obj in proxies_obj:
            dic_ = {
                'ip': proxy_obj.xpath('./td[1]/text()')[0].strip().replace('"', '').strip(),
                'port': proxy_obj.xpath('./td[2]/text()')[0].strip().replace('"', '').strip(),
                'type': proxy_obj.xpath('./td[3]/text()')[0].strip(),
                'position': proxy_obj.xpath('./td[5]/text()')[0].strip().split(' ')[0],
                'isp': proxy_obj.xpath('./td[5]/text()')[0].strip().split(' ')[1] if len(proxy_obj.xpath('./td[5]/text()')[0].strip().split(' ')) > 1 else None,
                'day': self.day
            }
            proxies.append(dic_)
        return proxies

    def get_all_proxies(self):
        """
        获取所有 proxies
        """
        # 1 先获取所有代理详情页的 url
        self.pre_parse()

        # 2 对每个 proxy 信息页的资源进行解析
        for parse_url in self.parse_urls:
            parse_url = 'https://www.zdaye.com' + parse_url

            self.update_attrs(url=parse_url)
            self.update_response()

            # 3 获取详情页所有的 proxy
            while True:
                time.sleep(3)   # 间隔爬取
                proxies = self.parse()
                if len(proxies) == 0:
                    break

                self.all_proxies += proxies

                next_tag = etree.HTML(self.response.text).xpath('//a[@title="下一页"]/@href')
                if len(next_tag) == 0:
                    break
                else:
                    next_page = 'https://www.zdaye.com' + etree.HTML(self.response.text).xpath('//a[@title="下一页"]/@href')[0]
                    self.update_attrs(url=next_page)
                    self.update_response()

        return self.all_proxies


if __name__ == '__main__':
    spider_zdy = SpiderZdaye()
    spider_zdy.run()

站大爷的网站需要注意的主要有两点:

  • 设置 verify=False 来关闭 SSL 验证;
  • 该网站的反爬措施还蛮严厉的,上述代码中尽管对请求进行了简单的 sleep(),但是只要多调试几次,还是会被封;

关于反爬,后面会讲解如何使用已爬取的代理来请求,并且站大爷这个网站还挺难搞的,后面会说到的。

3 爬虫链 & 使用代理绕过反爬

其实写到这里,如果要求不高的话,上面的功能已经可以做基本使用了,只需要挨个运行或者直接写一个主函数依次实例化并运行即可。但是上面已经说到了有些网站会有反爬限制,如果不使用代理,这个实例爬虫基本上就废掉了,所以下面我将多个实例爬虫排排队,串行执行,并在此过程中更新已有可用代理,并编写代理请求接口函数随机的获取一个可用代理,用来应对被限制的情况。

3.1 加载已有代理资源

看过 Spider 类代码的应该有印象,该项目将爬取并验证后的代理以字典的形式存到 txt 文件中,形式如下:

{"ip": "222.190.208.49", "port": "8089", "position": "江苏省泰州市", "isp": "电信", "day": "2023-04-13"}
{"ip": "36.137.106.110", "port": "7890", "position": "北京市", "isp": "移动", "day": "2023-04-13"}
{"ip": "182.241.132.30", "port": "80", "position": "云南省红河州", "isp": "电信", "day": "2023-04-13"}

并且多个实例爬虫是采用追加的形式向同一份文件追加写入的,那么我们自然可以简单的通过加载文件的方式来获取已有资源:

# 项目文件结构
-- proxies_spider_results:
	-- useful_proxies_2023-04-11.txt
	-- useful_proxies_2023-04-12.txt
-- ProxiesSpider:
	-- seo_spider.py
	-- kuai_spider.py
	-- zdaye_spider.py
-- main.py
-- tools.py
import sys
import json
import random
import os
from config import useful_ip_file_path

here = os.path.dirname(__file__)

def get_latest_proxy_file(file_path):
    """
    获取当前路径下的最新文件内容
    :param file_path:
    :return:
    """
    file_latest = sorted(os.listdir(file_path))[-1]
    with open(os.path.join(file_path, file_latest), 'r', encoding='utf=8') as f:
        all_free_proxies = [json.loads(s.strip()) for s in f.readlines()]

    return all_free_proxies

def get_free_proxy():
    all_free_proxies = get_latest_proxy_file(useful_ip_file_path)
    for i in tqdm(range(len(all_free_proxies)), file=sys.stdout, desc='choosing a useful proxy...'):
        index = random.randint(0, len(all_free_proxies)-1)
        proxy = all_free_proxies[index]
        useful, proxy = check_proxy_900cha(proxy)
        if useful:
            return proxy
    print('无可用 proxy ~')
    return None
	
if __name__ == '__main__':
    print(get_free_proxy())

这样,通过 get_free_proxy() 函数,就可以很容易的从已有代理中随机挑选一个可用的代理。

3.2 使用代理

有了上面的代理获取函数,在遇到反爬时,我们只需要调用一下,如果能返回一个可用的代理,那么就可以拿着这个代理去重新请求网页资源。

这里我以 kuai spider 为例,我们可以设置超时时间为 0.01 s,来模拟访问失败的情况,然后 catch 这个 Exception 并重新使用代理访问正确的网页:

# _*_ coding : utf-8 _*_
import sys
import os
sys.path.append(os.path.dirname(__name__))
from ProxiesSpider.spider import Spider
from lxml import etree
import time
import sys
from wrappers import req_respose_none_wrapper
from tqdm import tqdm
from tools import get_free_proxy

class SpiderKuai(Spider):

    def __init__(self, *args, **kwargs):

        kwargs['url'] = 'https://www.kuaidaili.com/free/inha/1/'
        kwargs['headers'] = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
        }

        super().__init__(**kwargs)

    def pre_parse(self):
        self.parse_urls = [
            'https://www.kuaidaili.com/free/intr/',     # 国内普通代理
            'https://www.kuaidaili.com/free/inha/'      # 国内高匿代理
        ]
        return self.parse_urls

    def parse(self):
        """
        解析代理
        """
        content = self.response.text
        tree = etree.HTML(content)
        proxies_obj = tree.xpath('//div[@id="list"]//tbody/tr')
        proxies = []
        for proxy_obj in proxies_obj:
            dic_ = {
                'ip': proxy_obj.xpath('./td[@data-title="IP"]/text()')[0].strip(),
                'port': proxy_obj.xpath('./td[@data-title="PORT"]/text()')[0].strip(),
                'type': proxy_obj.xpath('./td[@data-title="类型"]/text()')[0].strip(),
                'position': proxy_obj.xpath('./td[@data-title="位置"]/text()')[0].strip(),
                'day': proxy_obj.xpath('./td[@data-title="最后验证时间"]/text()')[0].strip().split(' ')[0]
            }
            if dic_['day'] != self.day:
                break
            proxies.append(dic_)
        return proxies

    def get_all_proxies(self):
        """
        获取所有 proxies
        """
        # 1 先获取所有待采集的 proxy list 页
        self.pre_parse()

        # 2 对每个 proxy 信息页的资源进行解析
        for parse_url in self.parse_urls:
            time.sleep(3)
            self.update_attrs(url=parse_url)
            self.update_response()

            # 3 获取资源页所有的 proxy
            count = 1
            pbar = tqdm(file=sys.stdout, desc='[{}] crawling all pages...'.format(self.__class__.__name__))
            while True:
                proxies = self.parse()
                if len(proxies) == 0:
                    break

                self.all_proxies += proxies

                next_page = '{}{}/'.format(parse_url, count+1)
                count += 1
                time.sleep(3)
                self.update_attrs(url=next_page)
                self.update_response()

                pbar.update(1)
            pbar.close()

        return self.all_proxies

if __name__ == '__main__':
    spider_kuai = SpiderKuai()
    spider_kuai.timeout = 0.01
    try:
        spider_kuai.run()
    except Exception as e:
        print(e)

        # 使用代理
        proxy = get_free_proxy()
        print('使用代理:', proxy)
        
        if proxy is None:
            proxies = None
        else:
            proxies = {
                'http': '{}:{}'.format(proxy['ip'], proxy['port']),
                'https': '{}:{}'.format(proxy['ip'], proxy['port']),
            }
        spider_kuai.timeout = 3
        spider_kuai.run()

执行结果:

~\python_spider\FreeIPProxyGettingPro_TMP> kuai_proxy_spider.py

HTTPSConnectionPool(host='www.kuaidaili.com', port=443): Max retries exceeded with url: /free/intr/ (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x00000183962DFB50>, 'Connection to www.kuaidaili.com timed out. (connect timeout=0.01)'))
choosing a useful proxy...:   6%|█████▊                   | 1/17 [00:06<01:38,  6.13s/it]
使用代理: {'ip': '182.241.132.30', 'port': '80', 'position': '云南省红河州', 'isp': '电信', 'day': '2023-04-13'}
[SpiderKuai] crawling all pages...: 2it [00:07,  3.72s/it]
[SpiderKuai] crawling all pages...: 2it [00:07,  3.69s/it]
[SpiderKuai] 爬取代理数:42
[SpiderKuai] checking proxies...: 100%|██████████████████████████████████████████████████████████| 42/42 [00:07<00:00,  5.48it/s]
[SpiderKuai] 可用代理数:0
[SpiderKuai] run successed.

可以看到,基本逻辑没问题。

4 鲁棒性

上一节介绍了如何使用爬取到的代理访问资源页,但是处理方式还是不够优雅。

我们可以想象一下,真实的爬虫运行出错场景是怎样的,首先肯定一开始是正常请求,结果报错了,此时我们应该暂停一下再次访问(确保不是网络本身的问题),如果还是不行就通过 get_free_proxy() 获取一个代理来进行请求,当然也不是无限次请求,我们暂且设置这种尝试次数不超过三次,之后如果还是报错,则停止这个爬虫。

4.1 request 请求出错处理逻辑

好了,有了这个基础的逻辑,我们就可以愉快的写代码了。
首先,为了解耦,我们肯定是不会在每个实例爬虫中像上一节中那样进行 try catch 的,别忘了 Spider 这个类,对 url 进行 requset 请求操作被封装在了 update_response() 这个函数中,所以我们可以直接对该函数进行 try catch。

在写之前梳理下逻辑:

  • try catch 住失败的 request,第一次失败,停一下,后面再尝试三次,为此我们需要一个计数参数,这个 Spider 中之前已经出现过了,就是 self.proxy_try_num = 0,初始时赋 0;

  • 尝试三次之后该网页就不再尝试爬取了,换下个网页,但是下个网页如果失败还是要走上面的逻辑,所以需要将 self.proxy_try_num 参数再次置零;

  • 第三次尝试失败时,由于后续的代理页还是需要继续爬取的,但由于此时 response = requests.get() 写法中,response 拿到的是 None,这会导致后续 parse() 过程报错(当然了,因为根本就没有爬取成功,自然没东西来解析嘛)。

    为此,还需要对 parse() 函数进行执行时异常捕获,这里我自定义了一个 TryWithSelfProxyLimitException 异常用来标识重试失败这种情况。

好了,话不多说看代码,关键节点都在注释中提示了:

# _*_ coding : utf-8 _*_
"""
基础 Spider 类
"""
import sys
import time
import requests
from tqdm import tqdm
from tools import check_proxy_900cha, get_free_proxy
from concurrent.futures import ProcessPoolExecutor, as_completed
import json
from config import *


class Spider:
    def __init__(self, *args, **kwargs):
	...
	self.proxy_try_num = 0      	# 设置使用代理时的全局失败尝试次数
	...

    def update_response(self, *args, **kwargs):
	try:
	    if self.proxy_try_num >= RETRY_LIMIT:
                # 重置尝试次数
                self.proxy_try_num = 0
		# 抛出自定义异常:超出尝试次数
		raise TryWithSelfProxyLimitException
		
	    if self.data is None:
		self.response = requests.get(self.url, headers=self.headers, timeout=self.timeout, proxies=self.proxies, verify=self.verify)
	    else:
		self.response = requests.post(self.url, headers=self.headers, data=self.data, timeout=self.timeout, proxies=self.proxies, verify=self.verify)
	except TryWithSelfProxyLimitException as e:
	    # 打印异常信息
            print('-' * 90)
            print('\033[1;31m{}\033[0m'.format(
                '[Trying times beyond the limit] [{}] | page: {} | e >>> {}'.format(self.__class__.__name__, self.url, e)
            ))
            print('-' * 90)
            # 直接返回 None
            return None
	except Exception as e:
	    print('\033[1;31m{}\033[0m'.format(
				'[{}][{}] | page: {} | e >>> {}'.format(self.proxy_try_num, self.__class__.__name__, self.url, e)
			))
	    if self.proxy_try_num == 0:
		# 先简单停一下
		print('\033[1;31m{}\033[0m'.format(
			'停一下停一下~'
		))
		time.sleep(10)
	    else:
		# 使用新代理重新请求
		proxy = get_free_proxy()
		if proxy is None:
		    proxies = None
		else:
		    proxies = {
			'http': '{}:{}'.format(proxy['ip'], proxy['port']),
			'https': '{}:{}'.format(proxy['ip'], proxy['port']),
		    }
		    print('[{}] use new proxy: {}'.format(self.__class__.__name__, proxies))
		    self.update_attrs(proxies=proxies)
		    # 尝试次数 +1
		    self.proxy_try_num += 1
		    # 更换代理后再次请求
		    return self.update_response()

        return self.response

	...

if __name__ == '__main__':
	help(Spider)

TryWithSelfProxyLimitException 定义在 custom_exceptions.py 中:

# _*_ coding : utf-8 _*_
"""
自定义异常类
"""

class TryWithSelfProxyLimitException(Exception):
    """
    Use yourself proxy to re-request a url, if exceed the up limit times, throws this exception
    """
    def __init__(self, obj=None):
        pass

    def __str__(self):
        return 'Attempt to use self proxy excedding limit of times!'

这样再次运行 seo_spider.py 时,就会先停一下,然后尝试 3 次,之后在 parse() 中报 NoneType 异常(因为返回的 response 为 None)。

至此,update_response() 这一层级的异常处理算是完成了,我们下面再在 parse() 上层对 NoneType 异常进行处理。
通常来说,我们要在 parse() 上层捕获 NoneType 异常,需要使用 try catch 包裹它,类似于下面这样:

try:
    self.parse()
except Exception as e:
    # 处理逻辑

但是我们的实例爬虫有多个,当然可以在每个实例爬虫代码中都写一遍,但这显然会使得代码后期维护起来变得更加困难,这里我们可以借助 python 装饰器,将处理逻辑封装到装饰器函数中。

关于 python 装饰器,简单来说就是一个自定义的外层函数,在被装饰函数执行前、后执行其他的逻辑,更详细的可以参考:

休息一下吧~

为了更直观的展示这里 parse() 中的 NoneType 异常确实是 3 次重试之后出现的,我自定义了一个 ResponseTextNoneException 异常(custom_exceptions.py):

# _*_ coding : utf-8 _*_
"""
自定义异常类
"""

class ResponseTextNoneException(Exception):
    """
    标识 self.response = None 的场景
    """
    def __init__(self):
        pass

    def __str__(self):
        return 'Self.response is None!'

异常已准备,开始写装饰器函数,捕获这个异常,我将所有装饰器函数都写在 wrappers.py 中:

# _*_ coding : utf-8 _*_
from functools import wraps
from custom_exceptions import ResponseTextNoneException

def req_respose_none_wrapper(func):
    """
    装饰 self.response = None 的场景
     """
    def inner(*args, **kwargs):
        obj = args[0]
        try:
            if obj.response is None:
                raise ResponseTextNoneException
            else:
                res = func(*args, **kwargs)
                return res
        except ResponseTextNoneException as e:
	    # 打印异常信息
            print('-' * 90)
            print('\033[1;31m{}\033[0m'.format(
                '[Request failed, response is None] [{}] | page: {} | e >>> {}'.format(obj.__class__.__name__, obj.url, e)
            ))
            print('-' * 90)
	    # 返回空列表,表示未爬取到代理资源
            return []
    return inner

定义好装饰器函数后,在实例爬虫中的相关函数之前通过 @ 方式配上:

# _*_ coding : utf-8 _*_
import sys
import os
sys.path.append(os.path.dirname(__name__))
from tools import *
from ProxiesSpider.spider import Spider
from wrappers import req_respose_none_wrapper


class SpiderSeo(Spider):

    def __init__(self, *args, **kwargs):

        url = 'https://proxy.seofangfa.com/'
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
        }

        super().__init__(url=url, headers=headers)

    def pre_parse(self):
        ...

    # 配上装饰器
    @req_respose_none_wrapper
    def parse(self):
        """
        解析代理
        """
        content = self.response.text
        tree = etree.HTML(content)
        proxies_obj = tree.xpath('//table[@class="table"]/tbody/tr')
        proxies = []
        for proxy_obj in proxies_obj:
            dic_ = {
                'ip': proxy_obj.xpath('./td[1]/text()')[0].strip(),
                'port': proxy_obj.xpath('./td[2]/text()')[0].strip(),
                'position': proxy_obj.xpath('./td[4]/text()')[0].strip(),
                'day': proxy_obj.xpath('./td[5]/text()')[0].strip().split(' ')[0],
            }
            proxies.append(dic_)
        return proxies

    def get_all_proxies(self):
        ...

再次运行 seo spider,输出结果如下:

[0][SpiderSeo] | page: https://proxy.seofangfa.com/ | e >>> HTTPSConnectionPool(host='proxy.seofangfa.com', port=443): Max retries exceeded with url: / (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x0000019101C217C0>, 'Connection to proxy.seofangfa.com timed out. (connect timeout=0.01)'))
停一下停一下~
[1][SpiderSeo] | page: https://proxy.seofangfa.com/ | e >>> HTTPSConnectionPool(host='proxy.seofangfa.com', port=443): Max retries exceeded with url: / (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x0000019101C3D130>, 'Connection to proxy.seofangfa.com timed out. (connect timeout=0.01)'))
choosing a useful proxy...:   0%|                                                                                                      | 0/28 [00:02<?, ?it/s]
[SpiderSeo] use new proxy: {'http': '36.137.158.200:7890', 'https': '36.137.158.200:7890'}
[2][SpiderSeo] | page: https://proxy.seofangfa.com/ | e >>> HTTPSConnectionPool(host='proxy.seofangfa.com', port=443): Max retries exceeded with url: / (Caused by ProxyError('Cannot connect to proxy.', timeout('timed out')))
choosing a useful proxy...:   0%|                                                                                                      | 0/28 [00:01<?, ?it/s]
[SpiderSeo] use new proxy: {'http': '58.32.1.58:8090', 'https': '58.32.1.58:8090'}
[3][SpiderSeo] | page: https://proxy.seofangfa.com/ | e >>> HTTPSConnectionPool(host='proxy.seofangfa.com', port=443): Max retries exceeded with url: / (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x0000019101C61760>, 'Connection to 58.32.1.58 timed out. (connect timeout=0.01)'))      
choosing a useful proxy...:   0%|                                                                                                      | 0/28 [00:01<?, ?it/s] 
[SpiderSeo] use new proxy: {'http': '180.184.91.187:443', 'https': '180.184.91.187:443'}
------------------------------------------------------------------------------------------
[Trying times beyond the limit] [SpiderSeo] | page: https://proxy.seofangfa.com/ | e >>> Attempt to use self proxy excedding limit of times!
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
[Request failed, response is None] [SpiderSeo] | page: https://proxy.seofangfa.com/ | e >>> The self.response is None!
------------------------------------------------------------------------------------------
[SpiderSeo] 爬取代理数:0
[SpiderSeo] checking proxies...: 0it [00:00, ?it/s]
[SpiderSeo] 可用代理数:0
[SpiderSeo] run successed.

可以看到,首先是触发了重试次数超限的异常,再触发了 self.response = None 的异常,足后返回 [],由于 seo 只有一个 url 资源页,所以爬虫运行紧随结束,爬取数为 0。

4.2 其他异常

到这里,常见异常的处理基本结束了。但是,在爬取站大爷资源的过程中,我发现当被限制爬取资源时,站大爷并不是直接拒绝请求从而导致 response 为 None,而是会返回一个 500 的页面,长下面这样,这样,爬取过程并不会报错,但是确实爬不到想要的资源,自然也不会触发上面所说的 3 次重试逻辑。

为了防止这种情况导致爬虫逻辑直接一次被略过,可以自定义异常并抛出,和其他异常一起触发上面的重试逻辑,我定义了一个 Request500Exception 异常(custom_exceptions.py):

# _*_ coding : utf-8 _*_
"""
自定义异常类
"""

class Request500Exception(Exception):
    """
    If we receive status code of 500 durning requesting, we'll get a special page(500-page).
    In this case, the spider runs successfully, but we do not get any data.
    """
    def __init__(self, obj=None):
	self.obj = obj if obj is not None else 'unknown'

	def __str__(self):
	    if self.obj == 'unknow':
		return 'The request response statu code is 500!'
	    else:
		return '{} | The request response statu code is 500!'.format(self.obj.__class__.__name__)

之后,针对这种情况,在 Spider 类中抛出:

# _*_ coding : utf-8 _*_
"""
定义基础 Spider 类
"""
import sys
import time
import requests
from wrappers import old_version_fun_wrapper, req_exceed_limit_wrapper, req_respose_none_wrapper
from tqdm import tqdm
from tools import check_proxy_icanhazip, check_proxy_900cha, get_free_proxy
from concurrent.futures import ProcessPoolExecutor, as_completed
import json
from config import *
from custom_exceptions import Request500Exception, TryWithSelfProxyLimitException, ResponseTextNoneException

class Spider:
    def __init__(self, *args, **kwargs):
        ...

    ...
		
    def update_response(self, *args, **kwargs):
        """
        recontruct a url request, and update the spider's response attribute
        :param args:
        :param kwargs:
        :return:
        """
        try:
            if self.proxy_try_num >= RETRY_LIMIT:
                # 重置尝试次数
                self.proxy_try_num = 0
                raise TryWithSelfProxyLimitException
            
            if self.data is None:
                self.response = requests.get(self.url, headers=self.headers, timeout=self.timeout, proxies=self.proxies, verify=self.verify)
            else:
                self.response = requests.post(self.url, headers=self.headers, data=self.data, timeout=self.timeout, proxies=self.proxies, verify=self.verify)

            # 自定义一些特定异常(在 catch Exceptino 中会触发重试逻辑)
            if self.response.status_code == 500:
                raise Request500Exception(self)

        except TryWithSelfProxyLimitException as e:
            print('-' * 90)
            print('\033[1;31m{}\033[0m'.format(
                '[Trying times beyond the limit] [{}] | page: {} | e >>> {}'.format(self.__class__.__name__, self.url, e)
            ))
            print('-' * 90)
            # 直接返回 None
            return None
        except Exception as e:
	    # 这里也会捕获 Request500Exception 异常
            print('\033[1;31m{}\033[0m'.format(
                '[{}][{}] | page: {} | e >>> {}'.format(self.proxy_try_num, self.__class__.__name__, self.url, e)
            ))
            if self.proxy_try_num == 0:
                # 先简单停一下
                print('\033[1;31m{}\033[0m'.format(
                    '停一下停一下~'
                ))
                time.sleep(5)
            else:
                # 使用新代理重新请求
                proxy = get_free_proxy()
                if proxy is None:
                    proxies = None
                else:
                    proxies = {
                        'http': '{}:{}'.format(proxy['ip'], proxy['port']),
                        'https': '{}:{}'.format(proxy['ip'], proxy['port']),
                    }
                print('[{}] use new proxy: {}'.format(self.__class__.__name__, proxies))
                self.update_attrs(proxies=proxies)
            self.proxy_try_num += 1
            return self.update_response()
        
        return self.response
	...

站大爷有多个资源页和详情页,为了证明我们的重试逻辑是正常有效的,可以在代码中记录下爬取成功和失败的页面,最后输出查看,zday spider 代码如下:

# _*_ coding : utf-8 _*_
import sys
import os
sys.path.append(os.path.dirname(__name__))
from lxml import etree
from ProxiesSpider.spider import Spider
import time
import sys
from wrappers import req_respose_none_wrapper

class SpiderZdaye(Spider):
    """
    zdaye 自有证书,需要设置 verify=False
    """
    def __init__(self, *args, **kwargs):

        url = 'https://www.zdaye.com/dayProxy.html'
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
        }

        super().__init__(url=url, headers=headers, verify=False)

    def pre_parse(self):
        """
        代理资源页解析
        :return:
        """
        self.update_response()

        self.response.encoding = 'utf-8'
        content = self.response.text
        tree = etree.HTML(content)
        proxy_page_info_obj = tree.xpath('//div[@class="thread_content"]/h3/a')
        for ppio in proxy_page_info_obj:
            title = ppio.xpath('./text()')[0].strip().split(' ')[0]
            parse_day = title.split('日')[0].replace('年', '-').replace('月', '-')
            if [int(x) for x in parse_day.split('-')] == [int(x) for x in self.day.split('-')]:
                self.parse_urls.append(ppio.xpath('./@href')[0])
            else:
                break
        return self.parse_urls

    @req_respose_none_wrapper
    def parse(self):
        """
        解析代理
        """
        self.response.encoding = 'utf-8'  # 注意该页面的编码
        content = self.response.text
        tree = etree.HTML(content)
        proxies_obj = tree.xpath('//table[@id="ipc"]/tbody/tr')
        proxies = []
        for proxy_obj in proxies_obj:
            dic_ = {
                'ip': proxy_obj.xpath('./td[1]/text()')[0].strip().replace('"', '').strip(),
                'port': proxy_obj.xpath('./td[2]/text()')[0].strip().replace('"', '').strip(),
                'type': proxy_obj.xpath('./td[3]/text()')[0].strip(),
                'position': proxy_obj.xpath('./td[5]/text()')[0].strip().split(' ')[0],
                'isp': proxy_obj.xpath('./td[5]/text()')[0].strip().split(' ')[1] if len(proxy_obj.xpath('./td[5]/text()')[0].strip().split(' ')) > 1 else None,
                'day': self.day
            }
            proxies.append(dic_)
        return proxies

    def get_all_proxies(self):
        """
        获取所有 proxies
        """
        # 1 先获取所有代理详情页的 url
        self.pre_parse()
        print(self.parse_urls)

        # for debug
        req_successed_urls = []		# 请求成功的页面
        req_failed_urls = []		# 请求失败的页面

        # 2 对每个 proxy 信息页的资源进行解析
        for parse_url in self.parse_urls:
            parse_url = 'https://www.zdaye.com' + parse_url

            self.update_attrs(url=parse_url)
            self.update_response()

            # for debug
            print('-' * 90)
            print(self.response.status_code)
            if self.response.status_code == 200:
				# 请求成功
                req_successed_urls.append(parse_url)
            else:
				# 请求失败
                req_failed_urls.append(parse_url)
			# 将页面存下看看
            self.response.encoding = 'utf-8'
            with open('zday.html', 'w', encoding='utf-8') as f:
                f.write(self.response.text)
                print('save the page screen shot')
            print('-' * 90)

            # 3 获取资源页所有的 proxy
            while True:
                time.sleep(3)   # 间隔爬取
                proxies = self.parse()
                if len(proxies) == 0:
                    break

                self.all_proxies += proxies

                next_tag = etree.HTML(self.response.text).xpath('//a[@title="下一页"]/@href')
                if len(next_tag) == 0:
                    break
                else:
                    next_page = 'https://www.zdaye.com' + etree.HTML(self.response.text).xpath('//a[@title="下一页"]/@href')[0]
                    self.update_attrs(url=next_page)
                    self.update_response()

                    # for debug
                    if self.response.status_code == 200:
                        req_successed_urls.append(next_page)
                    else:
                        req_failed_urls.append(next_page)

        # for debug
        if len(req_failed_urls) > 0:
            print('+' * 90)
            print('successed urls:', req_successed_urls)
            print('failed urls:', req_failed_urls)
            print('+' * 90)
        return self.all_proxies

if __name__ == '__main__':
    spider_zdy = SpiderZdaye()
    spider_zdy.run()

运行上面代码,输出如下:

~
['/dayProxy/ip/334680.html', '/dayProxy/ip/334679.html', '/dayProxy/ip/334678.html']
parse_url: https://www.zdaye.com/dayProxy/ip/334680.html
------------------------------------------------------------------------------------------
200
save the page screen shot
------------------------------------------------------------------------------------------
parse_url: https://www.zdaye.com/dayProxy/ip/334679.html
[0][SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334679.html | e >>> SpiderZdaye | The request response statu code is 500!
停一下停一下~
[1][SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334679.html | e >>> SpiderZdaye | The request response statu code is 500!
choosing a useful proxy...:   4%|▎         | 1/28 [00:05<02:19,  5.18s/it]
[SpiderZdaye] use new proxy: {'http': '183.221.242.107:8443', 'https': '183.221.242.107:8443'}
[2][SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334679.html | e >>> SpiderZdaye | The request response statu code is 500!
choosing a useful proxy...:  14%|█▍        | 4/28 [00:10<01:01,  2.56s/it]
[SpiderZdaye] use new proxy: {'http': '183.221.242.103:9443', 'https': '183.221.242.103:9443'}
[3][SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334679.html | e >>> SpiderZdaye | The request response statu code is 500!
choosing a useful proxy...:   0%|          | 0/28 [00:03<?, ?it/s]
[SpiderZdaye] use new proxy: {'http': '36.137.158.200:7890', 'https': '36.137.158.200:7890'}
------------------------------------------------------------------------------------------
[Trying times beyond the limit] [SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334679.html | e >>> Attempt to use self proxy excedding limit of times!
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
500
save the page screen shot
------------------------------------------------------------------------------------------
parse_url: https://www.zdaye.com/dayProxy/ip/334678.html
[0][SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334678.html | e >>> HTTPSConnectionPool(host='www.zdaye.com', port=443): Max retries exceeded with url: /dayProxy/ip/334678.html (Caused by ProxyError('Cannot connect to proxy.', timeout('_ssl.c:1112: The handshake operation timed out')))
停一下停一下~
[1][SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334678.html | e >>> HTTPSConnectionPool(host='www.zdaye.com', port=443): Max retries exceeded with url: /dayProxy/ip/334678.html (Caused by ProxyError('Cannot connect to proxy.', timeout('_ssl.c:1112: The handshake operation timed out')))
choosing a useful proxy...:   4%|▎         | 1/28 [00:06<02:58,  6.60s/it]
[SpiderZdaye] use new proxy: {'http': '111.225.152.224:8089', 'https': '111.225.152.224:8089'}
[2][SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334678.html | e >>> HTTPSConnectionPool(host='www.zdaye.com', port=443): Max retries exceeded with url: /dayProxy/ip/334678.html (Caused by ProxyError('Cannot connect to proxy.', timeout('timed out')))
choosing a useful proxy...:  32%|███▏      | 9/28 [00:34<01:13,  3.87s/it]
[SpiderZdaye] use new proxy: {'http': '182.241.132.30:80', 'https': '182.241.132.30:80'}
[3][SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334678.html | e >>> SpiderZdaye | The request response statu code is 500!
choosing a useful proxy...:   7%|▋         | 2/28 [00:07<01:39,  3.84s/it]
[SpiderZdaye] use new proxy: {'http': '180.184.91.187:443', 'https': '180.184.91.187:443'}
------------------------------------------------------------------------------------------
[Trying times beyond the limit] [SpiderZdaye] | page: https://www.zdaye.com/dayProxy/ip/334678.html | e >>> Attempt to use self proxy excedding limit of times!
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
500
save the page screen shot
------------------------------------------------------------------------------------------
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
successed urls: ['https://www.zdaye.com/dayProxy/ip/334680.html', 'https://www.zdaye.com/dayProxy/ip/334680/2.html', 'https://www.zdaye.com/dayProxy/ip/334680/3.html', 'https://www.zdaye.com/dayProxy/ip/334680/4.html', 'https://www.zdaye.com/dayProxy/ip/334680/5.html']
failed urls: ['https://www.zdaye.com/dayProxy/ip/334679.html', 'https://www.zdaye.com/dayProxy/ip/334678.html']
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
[SpiderZdaye] 爬取代理数:100
[SpiderZdaye] checking proxies...: 100%|██████████| 100/100 [00:40<00:00,  2.49it/s]
[SpiderZdaye] 可用代理数:3
[SpiderZdaye] run successed.

可以看到,有些页成功,有些页失败,整体逻辑没问题。

5 主函数

写到这里,上面的基础模块基本介绍完了,并且单个爬虫实例也测试完了。接下来要做的就是将整个项目的结构做个整合,通过入口函数根据爬虫链逐步执行。

5.1 参数配置文件

配置运行时的各种参数(config.py):

# _*_ coding : utf-8 _*_

"""
配置文件
"""

import os

RETRY_LIMIT = 4     # 爬取失败时的重试次数

# 可用 ip 存储文件地址
here_cfg = os.path.dirname(__file__)
useful_ip_file_path = os.path.join(here_cfg, 'proxies_spider_results')
# 可用 ip 存储文件名称
useful_ip_file_name = 'useful_proxies_spiding.txt'

5.2 存量数据验证

假设爬虫项目已经运行了多天,那么在开始启动实例爬虫保存当天的数据之前,其实昨天的爬取结果从逻辑上来说也是一份免费资源,并且当我们爬虫链中的第一个爬虫就需要代理时,我们正好可以从昨天的代理资源中抽取一个可用代理。

因此,在运行爬虫链之前,比较好的做法应该是先验证前一天爬取过的资源,本项目中,我将对前一天的资源验证操作也封装成一个爬虫实例,放在爬虫链的首位。
不过由于无需请求网络,该实例爬虫的逻辑和其他真实爬虫并不一样,所以无需继承 Spider 类,但为了爬虫链的统一运行逻辑,代码结构也需与其他实例爬虫保持一定的一致性(stock_proxy_spider.py):

# _*_ coding : utf-8 _*_

"""
前一天爬取的存量代理的验证(为保持结构一致性,也封装成 Spider 类形式)
"""

from tqdm import tqdm
from config import *
import json
from tools import get_latest_proxy_file
from concurrent.futures import ProcessPoolExecutor, as_completed
from tools import check_proxy_900cha
import time
import sys

class SpiderStock:

    def __init__(self):
        self.parse_urls = []
        self.all_proxies = []
        self.all_proxies_filter = []

    def pre_parse(self):
        """
        识别当天代理页面
        :return:
        """
        pass

    def parse(self):
        """
        解析代理
        """
        pass

    def get_all_proxies(self):
        """
        获取所有 proxies
        """
        self.all_proxies = get_latest_proxy_file(useful_ip_file_path)
        return self.all_proxies

    def filter_all_proxies_mp(self):
        """
        测试代理 ip 可用性
        多进程处理
        """
        self.all_proxies_filter = dict()

        # process pool
        pool = ProcessPoolExecutor(max_workers=50)
        all_task = [pool.submit(check_proxy_900cha, proxy) for proxy in self.all_proxies]
        for future in tqdm(as_completed(all_task), file=sys.stdout, total=len(all_task), desc='[{}] checking proxies...'.format(self.__class__.__name__)):
            res, proxy = future.result()
            if res:
                self.all_proxies_filter['{}:{}'.format(proxy['ip'], proxy['port'])] = proxy

        self.all_proxies_filter = self.all_proxies_filter.values()
        return self.all_proxies_filter

    def save_to_txt(self, file_name, all_proxies, add_day_tag=True):
        """
        存文件
        """
        day = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())).split(' ')[0].strip()
        if not os.path.isdir(os.path.dirname(file_name)):
            os.makedirs(os.path.dirname(file_name))
        if add_day_tag:
            file_name = file_name.split('.')[0] + '_{}.'.format(day.replace('-', '_')) + file_name.split('.')[-1]
        with open(file_name, 'w', encoding='utf-8') as f:
            for proxy in all_proxies:
                f.write(json.dumps(proxy, ensure_ascii=False) + '\n')
        # print(f'写入成功:{file_name}')

    def run(self):
        """
        General spider running logic:
            init -> face page url request -> (resource page collect) -> crawl all proxies -> check proxies' usability -> save
        :return:
        """
        self.get_all_proxies()
        self.filter_all_proxies_mp()
        self.save_to_txt(os.path.join(useful_ip_file_path, useful_ip_file_name), self.all_proxies_filter)


if __name__ == '__main__':
    stock_spider = SpiderStock()
    stock_spider.run()

用到的工具函数(tools.py):

# _*_ coding : utf-8 _*_

import sys
import requests
from lxml import etree
import json
import random
from tqdm import tqdm
import os
from config import useful_ip_file_path

here = os.path.dirname(__file__)

def get_latest_proxy_file(file_path):
    """
    获取当前路径下的最新文件内容
    :param file_path:
    :return:
    """
    file_latest = sorted(os.listdir(file_path))[-1]
    with open(os.path.join(file_path, file_latest), 'r', encoding='utf=8') as f:
        all_free_proxies = [json.loads(s.strip()) for s in f.readlines()]

    return all_free_proxies


def get_free_proxy():
    all_free_proxies = get_latest_proxy_file(useful_ip_file_path)
    for i in tqdm(range(len(all_free_proxies)), file=sys.stdout, desc='choosing a useful proxy...'):
        index = random.randint(0, len(all_free_proxies)-1)
        proxy = all_free_proxies[index]
        useful, proxy = check_proxy_900cha(proxy)
        if useful:
            return proxy
    print('无可用 proxy ~')
    return None

5.3 爬虫链配置文件

将多个实例爬虫串行组织(chain_of_spiders.py),存量数据验证 spider 放首位:

# _*_ coding : utf-8 _*_

# 自定义爬虫链

from ProxiesSpider import (
    kuai_proxy_spider,
    seo_proxy_spider,
    zdaye_proxy_spider,
    stock_proxy_spider
)


proxy_spiders = [
    stock_proxy_spider.SpiderStock,
    kuai_proxy_spider.SpiderKuai,
    seo_proxy_spider.SpiderSeo,
    zdaye_proxy_spider.SpiderZdaye
]

5.4 代理管理类(proxy manager)

为了更好的管理爬虫链中多个实例爬虫,我们可以定义一个逻辑上的总管,这个管理类可以管理多个实例爬虫,并为其提供一些服务。我们可以在管理类中添加任何你想用的小功能:

# _*_ coding : utf-8 _*_

"""
代理管理器
"""

import random
from tqdm import tqdm
from tools import check_proxy_900cha

class ProxyManager:

    def __init__(self):
        self.proxies = []
        self.proxies_set = set()

    def get_proxy(self):
	# 随机返回一个代理
        idx = random.randint(0, len(self.proxies)-1)
        return self.proxies[idx]

    def get_proxy_num(self):
	# 返回当前已爬取并验证过的代理数
        return len(self.proxies)

    def add_proxy(self, proxy):
	# 向管理类中添加一个代理
        proxy_str = "{}:{}".format(proxy['ip'], proxy['port'])
        if proxy_str not in self.proxies_set:
            self.proxies_set.add(proxy_str)
            self.proxies.append(proxy)

    def add_proxies(self, proxies):
	# 向管理类中添加一组代理
        for proxy in tqdm(proxies):
            proxy_str = "{}:{}".format(proxy['ip'], proxy['port'])
            if proxy_str not in self.proxies_set:
                self.proxies_set.add(proxy_str)
                self.proxies.append(proxy)

    def get_useful_proxy(self):
	# 获取一个可用的代理
        for i in tqdm(range(len(self.proxies))):
            proxy = self.get_proxy()
            if check_proxy_900cha(proxy['ip'], proxy['port']):
                return proxy
        print('无可用 proxy ~')
        return None

5.4 主函数

整个项目的入口(main.py):

# _*_ coding : utf-8 _*_

"""
项目入口
逻辑:
    1. 先开启全局代理持有器
    2. 进行存量数据验证
    3. 启动配置过的爬虫
    4. 保存
"""

from proxy_manager import ProxyManager
from ProxiesSpider.stock_proxy_spider import SpiderStock
from chain_of_spiders_cfg import *

if __name__ == '__main__':
    # global proxy manager
    proxy_manager = ProxyManager()
    print('ini useful proxy num:', proxy_manager.get_proxy_num())

    # 按序执行爬虫链
    spiders = [spider() for spider in proxy_spiders]
    for i, spider in enumerate(spiders):
        print('=' * 160)
        spider.run()
        proxy_manager.add_proxies(spider.all_proxies_filter)
        if i == 0:
            print('after stock proxies loading, the useful proxy num:', proxy_manager.get_proxy_num())

    print("proxy_manager's proxy num:", proxy_manager.get_proxy_num())

执行 main 文件,会自动的串行执行整个爬虫链。

6 差不多该结束了

写到这里,基本的结构都介绍完了,代码其实也不复杂,学习之旅告一段落,学习代码已上传至 git:https://github.com/sinat-jiang/FreeIPProxyGettingPro

posted @ 2023-04-13 21:56  sinatJ  阅读(884)  评论(0编辑  收藏  举报