主要针对以下四种反爬技术:Useragent过滤;模糊的Javascript重定向;验证码;请求头一致性检查。
高级网络爬虫技术:绕过 “403 Forbidden”,验证码等
爬虫的完整代码可以在 github 上对应的仓库里找到。
https://github.com/sangaline/advanced-web-scraping-tutorial
简介
我从不把爬取网页当做是我的一个爱好或者其他什么东西,但是我确实用网络爬虫做过很多事情。因为我所处理的许多工作都要求我得到无法以其他方式获得的数据。我需要为 Intoli 做关于游戏数据的静态分析,所以我爬取了Google应用商店的数据来寻找最新被下载的APK。Pointy Ball插件需要聚合来自不同网站的梦幻足球(游戏)的预测数据,最简单的方式就是写一个爬虫。在我在考虑这个问题的之前,我大概已经写了大约 40~50 个爬虫了。我不太记得当时我对我家人撒谎说我已经抓取了多少 TB 的数据….,但是我确实很接近那个数字了。
我尝试使用 xray/cheerio、nokogiri 和一些其他的工具。但我总是会回到我个人的最爱 Scrapy。在我看来,Scrapy是一个出色的软件。我对这款软件毫无保留的赞美是有原因的,它的用法非常符合直觉,学习曲线也很平缓。
你可以阅读Scrapy的教程,在几分钟内就可以让你的第一个爬虫运行起来。然后,当你需要做一些更复杂的事情的时候,你就会发现,有一个内置的、有良好文档说明的方式来做到这一点。这个框架有大量的内置功能,但是它的结构使得在你用到这些功能之前,不会妨碍到你。当你最终确实需要某些默认不存在的功能的时候,比如说,因为访问了太多的 URL 链接以至于无法存储到内存中,需要一个用于去重的 bloom filter(布隆过滤器),那么通常来说这就和继承其中的组件,然后做一点小改动一样简单。一切都感觉如此简单,而且scrapy是我书中一个关于良好软件设计的例子。
我很久以前就想写一个高级爬虫教程了。这给我一个机会来展示scrapy的可扩展性,同时解决实践中出现的现实问题。尽管我很想做这件事,但是我还是无法摆脱这样一个事实:因为发布一些可能导致他人服务器由于大量的机器人流量受到损害的文章,就像是一个十足的坏蛋。
只要遵循几个基本的规则,我就可以在爬取那些有反爬虫策略的网站的时候安心地睡个好觉。换句话说,我让我的请求频率和手动浏览的访问频率相当,并且我不会对数据做任何令人反感的事情。这样就使得运行爬虫收集数据基本上和以其他主要的手动收集数据的方法无法区分。但即使我遵守了这些规则,我仍感觉为人们实际想要爬取的网站写一个教程有很大的难度。
直到我遇到一个叫做Zipru的BT下载网站,这件事情仍然只是我脑海里一个模糊的想法。这个网站有多个机制需要高级爬取技术来绕过,但是它的 robots.txt 文件却允许爬虫爬取。此外,其实我们不必去爬取它。因为它有开放的API,同样可以得到全部数据。如果你对于获得torrent的数据感兴趣,那就只需要使用这个API,这很方便。
在本文的剩余部分,我将带领你写一个爬虫,处理验证码和解决我们在Zipru网站遇到的各种不同的挑战。样例代码无法被正常运行因为 Zipru 不是一个真实存在的网站,但是爬虫所使用的技术会被广泛应用于现实世界中的爬取中。因此这个代码在另一个意义上来说又是完整的。我们将假设你已经对 Python 有了基本的了解,但是我仍会尽力让那些对于 Scrapy 一无所知的人看懂这篇文章。如果你觉得进度太快,那么花几分钟的时间阅读一下Scrapy官网教程吧。
建立工程项目
我们会在 virtualenv 中建立我们的项目,这可以让我们封装一下依赖关系。首先我们在~/scrapers/zipru中创建一个virtualenv ,并且安装scrapy包。
- mkdir ~/scrapers/zipru
- cd ~/scrapers/zipru
- virtualenv env
- . env/bin/activate
- pip install scrapy
你运行的终端将被配置为使用本地的virtualenv。如果你打开另一个终端,那么你就需要再次运行. ~/scrapers/zipru/env/bin/active 命令 (否则你有可能得到命令或者模块无法找到的错误消息)。
现在你可以通过运行下面的命令来创建一个新的项目框架:
- scrapy startproject zipru_scraper
这样就会创建下面的目录结构。
- └── zipru_scraper
- ├── zipru_scraper
- │ ├── __init__.py
- │ ├── items.py
- │ ├── middlewares.py
- │ ├── pipelines.py
- │ ├── settings.py
- │ └── spiders
- │ └── __init__.py
- └── scrapy.cfg
大多数默认情况下产生的这些文件实际上不会被用到,它们只是建议以一种合理的方式来构建我们的代码。从现在开始,你应该把 ~/scrapers/zipru/zipru_scraper 当做这个项目的根目录。这里是任何scrapy命令运行的目录,同时也是所有相对路径的根。
添加一个基本的爬虫功能
现在我们需要添加一个Spieder类来让我们的scrapy真正地做一些事情。Spider类是scrapy爬虫用来解析文本,爬取新的url链接或是提取数据的一个类。我们非常依赖于默认Spider类的实现,以最大限度地减少我们必须要编写的代码量。这里要做的事情看起来有点自动化,但假如你看过文档,事情会变得更加简单。
首先,在zipru_scraper/spiders/目录下创建一个文件,命名为 zipru_spider.py ,输入下面内容。
- import scrapy
- class ZipruSpider(scrapy.Spider):
- name = 'zipru'
- start_urls = ['http://zipru.to/torrents.php?category=TV']
你可以在上面的网页中看到许多指向其他页面的连接。我们想让我们的爬虫跟踪这些链接,并且解析他们的内容。为了完成这个任务,我们首先需要识别出这些链接并且弄清楚他们指向的位置。
在这个阶段,DOM检查器将起到很大的助力。如果你右击其中的一个页面链接,在DOM检查器里面查看它,然后你就会看到指向其他页面的链接看起来像是这样的:
- <a href="/torrents.php?...page=2" title="page 2">2</a>
- <a href="/torrents.php?...page=3" title="page 3">3</a>
- <a href="/torrents.php?...page=4" title="page 4">4</a>
接下来我们需要为这些链接构造一个选择器表达式。有几种类型似乎用css或者xpath选择器进行搜索更适合,所以我通常倾向于灵活地混合使用这几种选择器。我强烈推荐学习xpath,但是不幸的是,它有点超出了本教程的范围。我个人认为xpath对于网络爬虫,web UI 测试,甚至一般的web开发来说都是不可或缺的。我接下来仍然会使用css选择器,因为它对于大多数人来说可能比较熟悉。
要选择这些页面链接,我们可以把 a[title ~= page] 作为一个 css 选择器,来查找标题中有 “page” 字符的 <a>标签。如果你在 DOM 检查器中按 ctrl-f,那么你就会发现你也可以使用这个css表达式作为一条查找语句(也可以使用xpath)。这样我们就可以循环查看所有的匹配项了。这是一个很棒的方法,可以用来检查一个表达式是否有效,并且表达式足够明确不会在不小心中匹配到其他的标签。我们的页面链接选择器同时满足了这两个条件。
为了讲解我们的爬虫是怎样发现其他页面的,我们在 ZipruSpider 类中添加一个 parse(response) 方法,就像下面这样:
- def parse(self, response):
- # proceed to other pages of the listings
- for page_url in response.css('a[title ~= page]::attr(href)').extract():
- page_url = response.urljoin(page_url)
- yield scrapy.Request(url=page_url, callback=self.parse)
当我们开始爬取的时候,我们添加到 start_urls 中的链接将会被自动获取到,响应内容会被传递到 parse(response) 方法中。之后我们的代码就会找到所有指向其他页面的链接,并且产生新的请求对象,这些请求对象将使用同一个 parse(response) 作为回调函数。这些请求将被转化成响应对象,只要 url 仍然产生,响应就会持续地返回到 parse(response)函数(感谢去重器)。
我们的爬虫已经可以找到了页面中列出的所有不同的页面,并且对它们发出了请求,但我们仍然需要提取一些对爬虫来说有用的数据。torrent 列表位于 <table> 标签之内,并且有属性 class="list2at" ,每个单独的 torrent 都位于带有属性 class="lista2" 的 <tr> 标签,其中的每一行都包含 8 个 <td>标签,分别与 “类别”,“文件”,“添加时间”,“文件大小”,“保种的人”,“下载文件的人”,“文件描述”,和“上传者”相对应。在代码中查看其它的细节可能是最简单的方法,下面是我们修改后的 parse(response) 方法:
- def parse(self, response):
- # proceed to other pages of the listings
- for page_url in response.xpath('//a[contains(@title, "page ")]/@href').extract():
- page_url = response.urljoin(page_url)
- yield scrapy.Request(url=page_url, callback=self.parse)
- # extract the torrent items
- for tr in response.css('table.lista2t tr.lista2'):
- tds = tr.css('td')
- link = tds[1].css('a')[0]
- yield {
- 'title' : link.css('::attr(title)').extract_first(),
- 'url' : response.urljoin(link.css('::attr(href)').extract_first()),
- 'date' : tds[2].css('::text').extract_first(),
- 'size' : tds[3].css('::text').extract_first(),
- 'seeders': int(tds[4].css('::text').extract_first()),
- 'leechers': int(tds[5].css('::text').extract_first()),
- 'uploader': tds[7].css('::text').extract_first(),
- }
我们的 parse(response) 方法现在能够返回字典类型的数据,并且根据它们的类型自动区分请求。每个字典都会被解释为一项,并且作为爬虫数据输出的一部分。
如果我们只是爬取大多数常见的网站,那我们已经完成了。我们只需要使用下面的命令来运行:
- scrapy crawl zipru -o torrents.jl
几分钟之后我们本应该得到一个 [JSON Lines] 格式 torrents.jl 文件,里面有我们所有的torrent 数据。取而代之的是我们得到下面的错误信息(和一大堆其他的东西):
- [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
- [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023
- [scrapy.core.engine] DEBUG: Crawled (403) <GET http://zipru.to/robots.txt> (referer: None) ['partial']
- [scrapy.core.engine] DEBUG: Crawled (403) <GET http://zipru.to/torrents.php?category=TV> (referer: None) ['partial']
- [scrapy.spidermiddlewares.httperror] INFO: Ignoring response <403 http://zipru.to/torrents.php?category=TV>: HTTP status code is not handled or not allowed
- [scrapy.core.engine] INFO: Closing spider (finished)
我好气啊!我们现在必须变得更聪明来获得我们完全可以从公共API得到的数据,因为上面的代码永远都无法爬取到那些数据。
简单的问题
我们的第一个请求返回了一个 403 响应,所以这个url被爬虫忽略掉了,然后一切都关闭了,因为我们只给爬虫提供了一个 url 链接。同样的请求在网页浏览器里运行正常,即使是在没有会话(session)历史的隐匿模式也可以,所以这一定是由于两者请求头信息的差异造成的。我们可以使用 tcpdump 来比较这两个请求的头信息,但其实有个常见错误,所以我们应该首先检查: user agent 。
Scrapy 默认把 user-agent 设置为 “Scrapy/1.3.3 (+http://scrapy.org)“,一些服务器可能会屏蔽这样的请求,甚至使用白名单只允许少量的user agent 通过。你可以在线查看 最常见的 user agent ,使用其中任何一个通常就足以绕过基本反爬虫策略。选择一个你最喜欢的 User-agent ,然后打开 zipru_scraper/settings.py ,替换 User agent
- # Crawl responsibly by identifying yourself (and your website) on the user-agent
- #USER_AGENT = 'zipru_scraper (+http://www.yourdomain.com)'
使用下面内容替换 USER_AGENT :
- USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36'
你可能注意到了,默认的scrapy设置中有一些令爬虫蒙羞的事。关于这个问题的观点众说纷纭,但是我个人认为假如你想让爬虫表现的像是一个人在使用普通的网页浏览器,那么你就应该把你的爬虫设置地像普通的网络浏览器那样。所以让我们一起添加下面的设置来降低一下爬虫响应速度:
- CONCURRENT_REQUESTS = 1
- DOWNLOAD_DELAY = 5
通过 AutoThrottle 扩展 ,上面的设置会创建一个稍微真实一点的浏览模式。我们的爬虫在默认情况下会遵守 robots.txt,所以现在我们的行为非常检点。
现在使用 scrapy crawl zipru -o torrents.jl 命令再次运行爬虫,应该会产生下面的输出:
- [scrapy.core.engine] DEBUG: Crawled (200) <GET http://zipru.to/robots.txt> (referer: None)
- [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (302) to <GET http://zipru.to/threat_defense.php?defense=1&r=78213556> from <GET http://zipru.to/torrents.php?category=TV>
- [scrapy.core.engine] DEBUG: Crawled (200) <GET http://zipru.to/threat_defense.php?defense=1&r=78213556> (referer: None) ['partial']
- [scrapy.core.engine] INFO: Closing spider (finished)
这是一个巨大的进步!我们获得了两个 200 状态码和一个 302状态码,下载中间件知道如何处理 302 状态码。不幸的是,这个 302 将我们的请求重定向到了一个看起来不太吉利的页面 threat_defense.php。不出所料,爬虫没有发现任何有用的东西,然后爬虫就停止运行了。
注: 假如网站检测到你的爬虫,那么网站就会把你的请求重定向到 threat_defense.php 页面,使你的爬虫失效,用来防止频繁的爬虫请求影响了网站正常用户的使用。
下载中间件
在我们深入研究我们目前所面临的更复杂的问题之前,先了解一下请求和响应在爬虫中是怎样被处理的,将会很有帮助。当我们创建了我们基本的爬虫,我们生成了一个 scrapy.Request 对象,然后这些请求会以某种方法转化为与服务器的响应相对应的 scrapy.Response对象。这里的 “某种方法” 很大一部分是来自于下载中间件。
下载中间件继承自 scrapy.downloadermiddlewares.DownloaderMiddleware 类并且实现了 process_request(request, spider) 和 process_response(request, response, spider) 方法。你大概可以从他们的名字中猜到他们是做什么的。实际上这里有一大堆的默认开启的中间件。下面是标准的中间件配置(你当然可以禁用、添加或是重新设置这些选项):
- DOWNLOADER_MIDDLEWARES_BASE = {
- 'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware': 100,
- 'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware': 300,
- 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware': 350,
- 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware': 400,
- 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': 500,
- 'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550,
- 'scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware': 560,
- 'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware': 580,
- 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 590,
- 'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': 600,
- 'scrapy.downloadermiddlewares.cookies.CookiesMiddleware': 700,
- 'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 750,
- 'scrapy.downloadermiddlewares.stats.DownloaderStats': 850,
- 'scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware': 900,
- }
当一个请求到达服务器时,他们会通过每个这些中间件的 process_request(request, spider) 方法。 这是按照数字顺序发生的,RobotsTxtMiddleware 中间件首先产生请求,并且 HttpCacheMiddleware 中间件最后产生请求。一旦接收到一个响应,它就会通过任何已启用的中间件的 process_response(request,response,spider) 方法来返回响应。这次是以相反的顺序发生的,所以数字越高越先发送到服务器,数字越低越先被爬虫获取到。
一个特别简单的中间件是 CookiesMiddleware。它简单地检查响应中请求头的 Set-Cookie,并且保存 cookie 。然后当响应返回的时候,他们会适当地设置 Cookie 请求头标记,这样这些标记就会被包含在发出的请求中了。这个由于时间太久的原因要比我们说的要稍微复杂些,但你会明白的。
另一个相对基本的就是 RedirectMiddleware 中间件,它是用来处理 3XX 重定向的。它让一切不是 3XX 状态码的响应都能够成功的通过,但假如响应中还有重定向发生会怎样? 唯一能够弄清楚服务器如何响应重定向URL的方法就是创建一个新的请求,而且这个中间件就是这么做的。当 process_response(request, response, spider) 方法返回一个请求对象而不是响应对象的时候,那么当前响应就会被丢弃,一切都会从新的请求开始。这就是 RedirectMiddleware 中间件怎样处理重定向的,这个功能我们稍后会用到。
如果你对于有那么多的中间件默认是开启的感到惊讶的话,那么你可能有兴趣看看 体系架构概览。实际上同时还有很多其他的事情在进行,但是,再说一次,scrapy的最大优点之一就是你不需要知道它的大部分原理。你甚至不需要知道下载中间件的存在,却能写一个实用的爬虫,你不必知道其他部分就可以写一个实用的下载中间件。
困难的问题
回到我们的爬虫上来,我们发现我们被重定向到某个 threat_defense.php?defense=1&... URL上,而不是我们要找的页面。当我们在浏览器里面访问这个页面的时候,我们看到下面的东西停留了几秒:
在被重定向到 threat_defense.php?defense=2&... 页面之前,会出现像下面的提示:
看看第一个页面的源代码就会发现,有一些 javascript 代码负责构造一个特殊的重定向URL,并且构造浏览器的cookies。如果我们想要完成这个任务,那我们就必须同时解决上面这两个问题。
接下来,当然我们也需要解决验证码并提交答案。如果我们碰巧弄错了,那么我们有时会被重定向到另一个验证码页面,或者我们会在类似于下面的页面上结束访问:
在上面的页面中,我们需要点击 “Click here” 链接来开始整个重定向的循环,小菜一碟,对吧?
我们所有的问题都源于最开始的 302 重定向,因此处理它们的方法自然而然应该是做一个自定义的 重定向中间件。我们想让我们的中间件在所有情况下都像是正常重定向中间件一样,除非有一个 302 状态码并且请求被重定向到 threat_defense.php 页面。当它遇到特殊的 302 状态码时,我们希望它能够绕过所有的防御机制,把访问cookie添加到 session 会话中,最后重新请求原来的页面。如果我们能够做到这一点,那么我们的Spider类就不必知道这些事情,因为请求会全部成功。
打开 zipru_scraper/middlewares.py 文件,并且把内容替换成下面的代码:
- import os, tempfile, time, sys, logging
- logger = logging.getLogger(__name__)
- import dryscrape
- import pytesseract
- from PIL import Image
- from scrapy.downloadermiddlewares.redirect import RedirectMiddleware
- class ThreatDefenceRedirectMiddleware(RedirectMiddleware):
- def _redirect(self, redirected, request, spider, reason):
- # act normally if this isn't a threat defense redirect
- if not self.is_threat_defense_url(redirected.url):
- return super()._redirect(redirected, request, spider, reason)
- logger.debug(f'Zipru threat defense triggered for {request.url}')
- request.cookies = self.bypass_threat_defense(redirected.url)
- request.dont_filter = True # prevents the original link being marked a dupe
- return request
- def is_threat_defense_url(self, url):
- return '://zipru.to/threat_defense.php' in url
你可能注意到我们继承了 RedirectMiddleware 类,而不是直接继承 DownloaderMiddleware 类。这样就允许我们重用大部分的重定向处理函数,并且把我们的代码插入到 _redirect(redirected, request, spider, reason) 函数中,一旦有重定向的请求被创建,process_response(request, response, spider) 函数就会调用这个函数。我们只是把对于普通的重定向的处理推迟到父类进行处理,但是对于特殊的威胁防御重定向的处理是不一样的。我们到目前为止还没有实现 bypass_threat_defense(url) 方法,但是我们可以知道它应该返回访问cookies,并把它附加到原来的请求中,然后原来的请求将被重新处理。
为了开启我们新的中间件,我们需要把下面的内容添加到 zipru_scraper/settings.py中:
- DOWNLOADER_MIDDLEWARES = {
- 'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': None,
- 'zipru_scraper.middlewares.ThreatDefenceRedirectMiddleware': 600,
- }
这会禁用默认的重定向中间件,并且把我们的中间件添加在中间件堆栈中和默认重定向中间件相同的位置。我们必须安装一些额外的包,虽然我们现在没有用到,但是稍后我们会导入它们:
- pip install dryscrape # headless webkit
- pip install Pillow # image processing
- pip install pytesseract # OCR
请注意,这三个包都有 pip 无法处理的外部依赖,如果你运行出错,那么你可能需要访问 dryscrape, Pillow, 和 pytesseract 的安装教程,遵循平台的具体说明来解决。
我们的中间件现在应该能够替代原来的标准重定向中间件,现在我们只需要实现 bypass_thread_defense(url) 方法。我们可以解析 javascript 代码来得到我们需要的变量,然后用 python 重建逻辑,但这看起来很不牢靠,而且需要大量的工作。让我们采用更简单的方法,尽管可能还是比较笨重,使用无头的 webkit 实例。有几个不同选择,但我个人比较喜欢 dryscrape (我们已经在上面安装了)
首先,让我们在中间件构造函数中初始化一个 dryscrape 会话。
- def __init__(self, settings):
- super().__init__(settings)
- # start xvfb to support headless scraping
- if 'linux' in sys.platform:
- dryscrape.start_xvfb()
- self.dryscrape_session = dryscrape.Session(base_url='http://zipru.to')
你可以把这个会话对象当做是一个单独的浏览器标签页,它可以完成一切浏览器通常可以做的事情(例如:获取外部资源,执行脚本)。我们可以在新的标签页中打开新的 URL 链接,点击一些东西,或者在输入框中输入内容,或是做其他的各种事情。Scrapy 支持并发请求和多项处理,但是响应的处理是单线程的。这意味着我们可以使用这个单独的 dryscrapy 会话,而不必担心线程安全。
现在让我们实现绕过威胁防御机制的基本逻辑。
- def bypass_threat_defense(self, url=None):
- # only navigate if any explicit url is provided
- if url:
- self.dryscrape_session.visit(url)
- # solve the captcha if there is one
- captcha_images = self.dryscrape_session.css('img[src *= captcha]')
- if len(captcha_images) > 0:
- return self.solve_captcha(captcha_images[0])
- # click on any explicit retry links
- retry_links = self.dryscrape_session.css('a[href *= threat_defense]')
- if len(retry_links) > 0:
- return self.bypass_threat_defense(retry_links[0].get_attr('href'))
- # otherwise, we're on a redirect page so wait for the redirect and try again
- self.wait_for_redirect()
- return self.bypass_threat_defense()
- def wait_for_redirect(self, url = None, wait = 0.1, timeout=10):
- url = url or self.dryscrape_session.url()
- for i in range(int(timeout//wait)):
- time.sleep(wait)
- if self.dryscrape_session.url() != url:
- return self.dryscrape_session.url()
- logger.error(f'Maybe {self.dryscrape_session.url()} isn\'t a redirect URL?')
- raise Exception('Timed out on the zipru redirect page.')
这样就处理了我们在浏览器中遇到的所有不同的情况,并且完全符合人类在每种情况中的行为。在任何给定情况下采取的措施都取决于当前页面的情况,所以这种方法可以稍微优雅一点地处理各种不同的情况。
最后一个难题是如果如何解决验证码。网上提供了 验证码识别 服务,你可以在必要时使用它的API,但是这次的这些验证码非常简单,我们只用 OCR 就可以解决它。使用 pytessertact 的 OCR 功能,最后我们可以添加 solve_captcha(img) 函数,这样就完善了 bypass_threat_defense() 函数。
- def solve_captcha(self, img, width=1280, height=800):
- # take a screenshot of the page
- self.dryscrape_session.set_viewport_size(width, height)
- filename = tempfile.mktemp('.png')
- self.dryscrape_session.render(filename, width, height)
- # inject javascript to find the bounds of the captcha
- js = 'document.querySelector("img[src *= captcha]").getBoundingClientRect()'
- rect = self.dryscrape_session.eval_script(js)
- box = (int(rect['left']), int(rect['top']), int(rect['right']), int(rect['bottom']))
- # solve the captcha in the screenshot
- image = Image.open(filename)
- os.unlink(filename)
- captcha_image = image.crop(box)
- captcha = pytesseract.image_to_string(captcha_image)
- logger.debug(f'Solved the Zipru captcha: "{captcha}"')
- # submit the captcha
- input = self.dryscrape_session.xpath('//input[@id = "solve_string"]')[0]
- input.set(captcha)
- button = self.dryscrape_session.xpath('//button[@id = "button_submit"]')[0]
- url = self.dryscrape_session.url()
- button.click()
- # try again if it we redirect to a threat defense URL
- if self.is_threat_defense_url(self.wait_for_redirect(url)):
- return self.bypass_threat_defense()
- # otherwise return the cookies as a dict
- cookies = {}
- for cookie_string in self.dryscrape_session.cookies():
- if 'domain=zipru.to' in cookie_string:
- key, value = cookie_string.split(';')[0].split('=')
- cookies[key] = value
- return cookies
你可能注意到如果验证码因为某些原因识别失败的话,它就会委托给 back to the bypass_threat_defense() 函数。这样就给了我们多次识别验证码的机会,但重点是,我们会在得到正确结果之前一直在验证码识别过程中循环。
这应该足够让我们的爬虫工作,但是它有可能陷入死循环中。
- [scrapy.core.engine] DEBUG: Crawled (200) <GET http://zipru.to/robots.txt> (referer: None)
- [zipru_scraper.middlewares] DEBUG: Zipru threat defense triggered for http://zipru.to/torrents.php?category=TV
- [zipru_scraper.middlewares] DEBUG: Solved the Zipru captcha: "UJM39"
- [zipru_scraper.middlewares] DEBUG: Zipru threat defense triggered for http://zipru.to/torrents.php?category=TV
- [zipru_scraper.middlewares] DEBUG: Solved the Zipru captcha: "TQ9OG"
- [zipru_scraper.middlewares] DEBUG: Zipru threat defense triggered for http://zipru.to/torrents.php?category=TV
- [zipru_scraper.middlewares] DEBUG: Solved the Zipru captcha: "KH9A8"
- ...
至少看起来我们的中间件已经成功地解决了验证码,然后补发了请求。问题在于,新的请求再次触发了威胁防御机制。我第一个想法是我可能在怎样解析或是添加cookie上面有错误,但是我检查了三次,代码是正确的。这是另外一种情况 “唯一可能不同的事情就是请求头” 。
很明显,scrapy 和 dryscrape 的请求头都绕过了最初的触发 403 响应的过滤器,因为我们现在不会得到任何 403 的响应。这肯定是因为它们的请求头信息不一致导致的。我的猜测是其中一个加密的访问cookies包含了整个请求头信息的散列值,如果这个散列不匹配,就会触发威胁防御机制。这样的目的可能是防止有人把浏览器的cookie复制到爬虫中去,但是它只是增加了你需要解决的问题而已。
所以让我们在 zipru_scraper/settings.py 中把请求头信息修改成下面这个样子。
- DEFAULT_REQUEST_HEADERS = {
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
- 'User-Agent': USER_AGENT,
- 'Connection': 'Keep-Alive',
- 'Accept-Encoding': 'gzip, deflate',
- 'Accept-Language': 'en-US,*',
- }
注意我们已经把 User-Agent 头信息修改成了我们之前定义的 USER_AGENT 中去.这个工作是由 user agent 中间件自动添加进去的,但是把所有的这些配置放到一个地方可以使得 dryscrape 更容易复制请求头信息。我们可以通过修改 ThreatDefenceRedirectMiddleware 初始化函数像下面这样:
- def __init__(self, settings):
- super().__init__(settings)
- # start xvfb to support headless scraping
- if 'linux' in sys.platform:
- dryscrape.start_xvfb()
- self.dryscrape_session = dryscrape.Session(base_url='http://zipru.to')
- for key, value in settings['DEFAULT_REQUEST_HEADERS'].items():
- # seems to be a bug with how webkit-server handles accept-encoding
- if key.lower() != 'accept-encoding':
- self.dryscrape_session.set_header(key, value)
现在,当我们可以通过命令 scrapy crawl zipru -o torrents.jl 再次运行爬虫。我们可以看到源源不断的爬取的内容,并且我们的 torrents.jl 文件记录把爬取的内容全部记录了下来。我们已经成功地绕过了所有的威胁防御机制。
总结
我们已经成功地写了一个能够解决四种截然不同的威胁防御机制的爬虫,这四种防御机制分别是:
- User agent 过滤
- 模糊的 Javascript 重定向
- 验证码
- 请求头一致性检查
我们的目标网站 Zipru 可能是虚构的,但是这些机制都是你会在真实网站上遇到的真实的反爬虫技术。希望我们使用的方法对你自己爬虫中遇到的挑战有帮助。