scrapy学习之爬虫练习平台5

前言

本篇文章是这个爬虫练习平台的最后一篇了,由于是学习 Scrapy,所以前面跳过了验证码逆向的题目,后面跳过了 APK 逆向的题目,验证码和 APK 看情况以后再单独写文章。本篇文章写使用代理 IP 突破 IP 地址反爬。

环境配置

本次使用的环境和 ssr1 的环境是一样的,不使用 selenium,数据库仍然使用 Mongo。

在开始写代码之前还需要搭建一个代理池,由于这里只是学习 Scrapy,就不自己搭建了,使用 GitHub 开源的 proxy_pool,就可以满足需求。

代理池地址在这里,搭建教程在 README 中已经写得很清楚了,就不重复了。

但是有一点内容要强调一下,记得将代理验证 URL 改为需要爬取的网站,因为代理池里边的代理 IP 不一定对所有的网站都是可以代理的,默认是能访问百度就认为代理 IP 可用, 但是可能这个代理 IP 已经被对应网站封掉了,所以需要针对性的检测才可以得到质量相对比较高的代理池。

开始爬取

antispider5

antispider5 说明如下:

限制单个 IP 访问频率 5 分钟最多 10 次,如果过多则会封禁 IP 10 分钟。

访问过多会封禁 IP,限制条件是单个 IP 的访问次数。

手动刷新几次,被封禁了,状态码是 403,说明 IP 被封禁时会返回 403,在爬虫代码中要注意判断 403 错误代码,在遇到 403 时要更换代理 IP 重试请求。

开始写代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Antispider5DownloaderMiddleware(LearnscrapyDownloaderMiddleware):
def __init__(self):
super(Antispider5DownloaderMiddleware, self).__init__()

@property
def proxy(self):
return 'http://' + requests.get('http://192.168.233.128:5010/get').json()['proxy']

def process_request(self, request: Request, spider):
# 将请求加上代理
request.meta['proxy'] = self.proxy

def process_response(self, request, response: Response, spider):
# 如果响应代码为403,则将请求更换一个新代理重新请求
if response.status == 403:
print(f'重试:{response.url}')
request.meta['proxy'] = self.proxy
return request
else:
return response

其他代码和之前爬取的代码基本一致,唯一不同的就是下载中间件这里,继承原来的下载中间件,重写两个方法 process_requestprocess_response,其中 process_request 用来在每个请求被发出去之前添加一个代理 IP,process_response 用来判断响应的状态码,如果状态码为403,证明代理 IP 已经被封禁,需要更换代理 IP,之后返回一个 request 对象,稍后调度器会将它重新添加到下载引擎,重新下载。如果状态码正常,返回 response 对象, 正常对响应进行处理。

process_response 还可以添加以下删除代理 IP 的代码,在代理 IP 返回 503 错误代码或者其他错误代码时,删除已经不可用的代理 IP,防止大量失败的重试降低爬取效率。

运行爬虫,查看日志输出。

可以看到有一些代理在我之前的调试过程中已经访问过网站几次,再次访问就被 ban 了,需要更换新的代理 IP 重新请求。

稍等片刻,时间长短取决于代理 IP 的质量,待爬虫爬取结束查看数据是否完整。

完整代码详见https://github.com/libra146/learnscrapy/tree/antispider5

Antispider6

antispider6 说明如下:

限制单个账号访问频率 5 分钟最多 10 次,如果过多则会暂停访问 10 分钟。

限制条件为账号限制,那么就对症下药,准备多个帐号,来将请求分散到多个账号中,逃过单个账号频率限制。

首先需要使用脚本注册多个账号备用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"""
注册多个账号备用,将多个请求分散到多个账号中。
"""
import requests


def register():
url = 'https://antispider6.scrape.center/register'
# 简单设置账号密码邮箱为同一个值
for a in range(10):
d = f'12345-{a}@qq.com'
data = {
'username': d,
'email': d,
'password1': d,
'password2': d
}
r = requests.post(url=url, data=data)
print(f'12345-{a}@qq.com-{r.status_code}')


if __name__ == '__main__':
register()

很简单的脚本,批量循环注册就可以了。

在 scrapy 启动时先登录这一批账号,建立 cookie 池,之后在发送请求时从 cookie 池中随机选取 cookie 替换当前的 cookie。

调试过程中,发现登录请求只会被发送一次,cookie 池中也只有一条数据,但是日志太少,无法找到问题来源。开启 DEBUG 日志后发现产生了这么一条日志:

1
2020-10-26 19:59:21 [scrapy.dupefilters] DEBUG: Filtered duplicate request: <GET https://antispider6.scrape.center/> - no more duplicates will be shown (see DUPEFILTER_DEBUG to show all duplicates)

意思就是后面的请求被过滤掉了,这是 scrapy 自带的 URL 去重功能,因为登录 URL 都是一样的,只有提交的账号密码不一样,所以被 scrapy 认为是重复的请求,后面的请求被过滤了,导致只能登录一次的问题。

对于需要重复发送的 URL,只需要将 FormRequest 类添加 dont_filter=True 参数即可关掉 URL 去重功能。

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
class Antispider6(scrapy.Spider):
name = 'antispider6'
# 建立cookie池
cookies = []

def start_requests(self):
login_url = 'https://antispider6.scrape.center/login'
for a in range(10):
# 在循环中使用刚才注册的那几个账号登录,dont_filter参数用来防止不同的请求被过滤掉,URL去重默认是打开的
yield scrapy.FormRequest(url=login_url, callback=self.add_cookie,
formdata={'username': f'123456-{a}@qq.com', 'password': f'123456-{a}@qq.com'},
dont_filter=True)

def add_cookie(self, response: Response):
# 将cookie保存起来
self.cookies.append((response.request.headers.getlist('Cookie')))
# 当全部账号都已经登录成功后即可开始爬取流程
if len(self.cookies) >= 10:
print('登录完成,开始爬取...')
for a in range(1, 8):
yield scrapy.Request(url=f'https://antispider6.scrape.center/page/{a}', callback=self.parse)

def parse(self, response, **kwargs):
result = response.xpath('//div[@class="el-card item m-t is-hover-shadow"]')
for a in result:
item = Antispider6ScrapyItem()
item['title'] = a.xpath('.//h2[@class="m-b-sm"]/text()').get()
item['fraction'] = a.xpath('.//p[@class="score m-t-md m-b-n-sm"]/text()').get().strip()
item['country'] = a.xpath('.//div[@class="m-v-sm info"]/span[1]/text()').get()
item['time'] = a.xpath('.//div[@class="m-v-sm info"]/span[3]/text()').get()
item['date'] = a.xpath('.//div[@class="m-v-sm info"][2]/span/text()').get()
url = a.xpath('.//a[@class="name"]/@href').get()
yield Request(url=response.urljoin(url), callback=self.parse_person, meta={'item': item})

def parse_person(self, response):
item = response.meta['item']
item['director'] = response.xpath(
'//div[@class="directors el-row"]//p[@class="name text-center m-b-none m-t-xs"]/text()').get()
yield item

运行爬虫,先将账号都进行登录动作,然后保存 cookie,等到账号全部登录完成时,进行下一步动作,正常爬取。

打个断点查看下。

可以看到,十个 cookie 已经全部获取到了, 接下来可以循环爬取各个页面了。

在爬虫运行中有一个问题,需要提前计算好需要爬取多少个界面,每个账号可以请求多少次,最后得出最少需要多少个账号才可以完成整个采集流程。如果 cookie 不够的话会导致大量的 403 请求,从而导致丢失数据,无法正常爬取。所以说为了保证可以正常爬取最好是准备足够多的账号来保证可以完成整个爬取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Antispider6SpiderMiddleware(LearnscrapySpiderMiddleware):
def __init__(self):
super(Antispider6SpiderMiddleware, self).__init__()

def process_request(self, request: Request, spider):
if len(spider.cookies) >= 10:
# 更换cookie
request.cookies = dict([random.choice(spider.cookies)[0].decode().split('=')])
# 为什么这里要返回None,不能返回request?返回request爬虫会直接关闭,无法接续爬,下篇文章中来解释
return None

def process_response(self, request: Request, response: Response, spider):
# 如果遇到了403,更换cookie,和更换代理IP的思路是一样的
if response.status == 403:
print('cookie被ban,更换cookie')
request.cookies = dict([random.choice(spider.cookies)[0].decode().split('=')])
return request
else:
return response

在登录完成之后将每个请求都添加随机的 cookie,在响应函数进行状态码判断,如果状态码为 403,则随机更换一个新的 cookie。

这里有一个问题,注释中写了,为什么更换 cookie 之后不能返回 request 对象?如果返回了 request 对象爬虫不会调用 process_response 函数,会直接关闭。

这个问题在下一篇文章中来解释,因为这个卡了一个小时。🤣🤣

到这里爬虫已经可以正常运行了,使用爬虫开始时获取的 cookie 池基本可以满足爬取所有页面的需求。

完整代码详见https://github.com/libra146/learnscrapy/tree/antispider6

antispider7

antispider7 说明如下:

限制单个 IP 访问频率 5 分钟最多 10 次,同时限制单个账号访问频率 5 分钟最多 10 次,如果过多则会封禁 IP 或账号 10 分钟。

既限制 IP,又限制账号,那么就将 antispider5 和 antispider6 的代码组合起来就可以了。

这里要注意一个问题,那就是如果 IP 被 ban 和账号被 ban 的状态码都是 403 的话,怎么判断是因为哪个原因引起的?

我的解决方案就是在每个请求中添加 meta 标记,更换 cookie 和更换 IP 之后要做标记,下次再碰到 403 状态码时根据标记更换 IP 或者 cookie。

记得先使用脚本注册一定量的账号。

调试过程中发现这个网站使用的不是 cookie,使用的是 token(JWT),需要将 token 保存起来放到 cookie 池中,稍微修改一下保存 cookie 的代码。

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
class Antispider7(scrapy.Spider):
name = 'antispider7'
# 建立cookie池
cookies = []
offset = 0
limit = 18
url = f'https://antispider7.scrape.center/api/book/?limit={limit}&offset=%s'

def start_requests(self):
login_url = 'https://antispider7.scrape.center/api/login'
# login_url = 'http://httpbin.org'
for a in range(10):
# 使用json格式登录,不使用表单
yield JsonRequest(url=login_url, callback=self.add_cookie, method='POST',
data={'username': f'12345678{a}', 'password': f'12345678{a}'},
dont_filter=True)

def add_cookie(self, response):
# 将cookie保存起来
self.cookies.append(response.json()['token'])
# 当全部账号都已经登录成功后即可开始爬取流程
if len(self.cookies) >= 10:
print('登录完成,开始爬取...')
urls = [self.url % self.offset]
for url in urls:
yield scrapy.Request(url=url, callback=self.parse, dont_filter=True,
meta={'change_proxy': 0, 'change_cookie': 0})

def parse(self, response, **kwargs):
result = response.json()
print(response.url)
for a in result['results']:
item = Antispider7ScrapyItem()
item['title'] = a['name']
item['author'] = '/'.join(a['authors'] or [])
yield Request(url=f'https://antispider7.scrape.center/api/book/{a["id"]}/', callback=self.parse2,
meta={'item': item, 'change_proxy': 0, 'change_cookie': 0})
# 原始数据量太大了,这里只爬取200条
if 200 > self.offset:
self.offset += self.limit
yield Request(url=self.url % self.offset, callback=self.parse)

def parse2(self, response):
item = response.meta['item']
result = response.json()
item['price'] = result['price'] or 0
item['time'] = result['published_at']
item['press'] = result['publisher']
item['page'] = result['page_number']
item['isbm'] = result['isbn']
yield item

其中需要注意的地方已经用注释标记出来了。

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
class Antispider7SpiderMiddleware(LearnscrapySpiderMiddleware):
def __init__(self):
super(Antispider7SpiderMiddleware, self).__init__()

@property
def proxy(self):
return 'http://' + requests.get('http://192.168.233.128:5010/get').json()['proxy']

def process_request(self, request: Request, spider):
if len(spider.cookies) >= 10:
request.headers.update({'authorization': f'jwt {random.choice(spider.cookies)}'})
request.meta['proxy'] = self.proxy
return None

def process_response(self, request: Request, response: Response, spider):
# 如果遇到了403,更换cookie或代理
if response.status == 403:
if request.meta['change_proxy'] == 0 and request.meta['change_cookie'] == 0:
# 先换cookie
print('cookie被ban,更换cookie')
request.headers.update({'authorization': f'jwt {random.choice(spider.cookies)}'})
request.meta['change_cookie'] = 1
elif request.meta['change_proxy'] == 0 and request.meta['change_cookie'] == 1:
print(f'重试:{response.url}')
request.meta['proxy'] = self.proxy
request.meta['change_proxy'] = 1
elif request.meta['change_proxy'] == 1 and request.meta['change_cookie'] == 0:
print('cookie被ban,更换cookie')
request.meta['change_cookie'] = 1
request.headers.update({'authorization': f'jwt {random.choice(spider.cookies)}'})
else:
request.headers.update({'authorization': f'jwt {random.choice(spider.cookies)}'})
request.meta['proxy'] = self.proxy
print('两个都换')
return request
else:
return response

下载中间件中要根据标记来更换代理 IP 或者 cookie。

运行爬虫, 开始爬取,我的代理 IP 质量不是特别好,所以就爬取了两百条作为测试。

完整代码详见https://github.com/libra146/learnscrapy/tree/antispider7

总结

一番调试下来,发现代理 IP 的质量真的对爬虫有很大影响,好多代理速度特别慢,特别影响爬取效率。

以上方法仅限于学习 scrapy,肯定还有更好的解决方案并且以上方案也不适用于大规模爬取和分布式爬取。例如 cookie 池可以使用共享队列来实现,也可以做成分布式,还有代理池也是一样, 可以单独做成服务来运行。在以上方案的基础上可以做好多拓展以满足大型项目的需要和分布式爬取的需要。

本文章首发于个人博客 LLLibra146’s blog
本文作者:LLLibra146
版权声明:本博客所有文章除特别声明外,均采用 © BY-NC-ND 许可协议。非商用转载请注明出处!严禁商业转载!
本文链接https://blog.d77.xyz/archives/860d8413.html