Advanced Web Scraping: Bypassing "403 Forbidden," captchas, and more
—— github
我嘗試過x-ray/cheerio, nokogiri等等爬蟲框架,最終還是回到了我的最愛: scrapy。它確實非常直觀,學習曲線友好。
通過The Scrapy Tutorial(中文版)你可以在幾分鐘之內(nèi)上手你的第一只爬蟲。然后,當你需要完成一些復雜的任務時,你很可能會發(fā)現(xiàn)有內(nèi)置好的、文檔良好的方式來實現(xiàn)它。(Scrapy內(nèi)置了許多強大的功能,但Scrapy的框架結(jié)構良好,如果你還不需要某個功能,它就不會影響你。)再者,如果你最終需要某些新功能,例如一個布隆過濾器來去重大量的鏈接,通常只需要簡單地子類化某個組件,并做點小小的修改就可以了。
- 注:作者為了爬蟲道德,在教程中使用了虛擬的目標網(wǎng)站Zipru,假想它為一個種子站點。自己嘗試時,修改為自己的需求網(wǎng)址即可。
新建項目
- scrapy安裝參看新手向爬蟲(三)別人的爬蟲在干啥
- 命令行新建名為
zipru_scraper的工程目錄。
scrapy startproject zipru_scraper
- 這會創(chuàng)建如下的目錄結(jié)構:
└── zipru_scraper
├── zipru_scraper
│ ├── __init__.py
│ ├── items.py
│ ├── middlewares.py
│ ├── pipelines.py
│ ├── settings.py
│ └── spiders
│ └── __init__.py
└── scrapy.cfg
默認情況下,大多數(shù)這些文件實際上不會被使用,它們的存在只是建議我們以一個合理的方式組織我們的代碼。當前,你只需要考慮zipru_scraper(第一個)作為項目的頂層目錄,這就是任何scrapy命令應該運行的地方,也是任何相對路徑的根。
添加一個基本爬蟲
- 現(xiàn)在我們需要添加一個小爬蟲來真正做點什么。
- 創(chuàng)建文件
zipru_scraper/spiders/zipru_spider.py添加如下內(nèi)容:
import scrapy
class ZipruSpider(scrapy.Spider):
name = 'zipru'
start_urls = ['http://zipru.to/torrents.php?category=TV']
- 我們的小爬蟲繼承了
scrapy.Spider,它內(nèi)置的start_requests()方法會自動遍歷start_urls列表中的鏈接來開始我們的抓取。我們先嘗試一個類似下圖的種子列表頁面。
對頁碼審查元素我們會看到:
<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>
- 為了讓我們的小爬蟲知道如何爬取這些鏈接,我們需要為
ZipruSpider類添加一個parse(response)方法:(頁面元素選取可參看Selectors選擇器簡介或新手向爬蟲(一)利用工具輕松爬取簡書并分析)
def parse(self, response):
# 從頁面中取出頁碼里包含的鏈接
for page_url in response.css('a[title ~= page]::attr(href)').extract():
page_url = response.urljoin(page_url)
# 將解析出的href里的鏈接自動判斷補全
yield scrapy.Request(url=page_url, callback=self.parse)
# 由解析出的url生成新的請求對象
- 在爬取從
start_urls自動開始后,服務器返回的響應會自動傳遞給parse(self, response)方法。在解析了頁面中的頁碼元素里包含的鏈接后,我們由每個解析出的url生成新的請求對象,它們的響應的解析方法即回調(diào)函數(shù)還是這個parse方法。只要這些url還沒被處理過,這些請求將被轉(zhuǎn)換為響應對象,仍然饋入parse(self, response)方法(感謝dupe過濾器幫我們自動去重鏈接)。 - 現(xiàn)在我們的小爬蟲已經(jīng)可以不斷地爬取新的列表頁面了。但是我們還沒有獲取到實際的信息。讓我們加上對表格中元素的解析來獲取種子信息。
def parse(self, response):
# 從頁面中取出頁碼里包含的鏈接
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)
# 提取種子信息
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(),
}
- 在解析出url生成新的請求后,我們的小爬蟲會處理當前頁面的種子信息,生成一個字典項目,作為我們爬蟲數(shù)據(jù)輸出的一部分。(Scrapy會根據(jù)類型自動判斷我們
yield產(chǎn)出的是新的請求還是數(shù)據(jù)信息。)
對大多數(shù)網(wǎng)站的數(shù)據(jù)抓取來說,我們的任務就已經(jīng)完成了。在命令行運行:
scrapy crawl zipru -o torrents.jl
幾分鐘后,一個格式良好的JSON Lines文件torrents.jl就生成了,包含了我們需要的種子信息。相反,我們會得到這樣的輸出:
[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響應,它會被忽略,然后因為我們只給了一個目標網(wǎng)址爬蟲停止了。 - 如果手動瀏覽器訪問該鏈接顯示正常,我們可以用tcpdump來比較兩次請求的差異。但我們首先需要檢查的是User Agent參數(shù)。
- 默認情況下,Scrapy將其標識為“Scrapy / 1.3.3(+ http://scrapy.org)”,某些服務器可能會阻止它或者甚至只是將有限數(shù)量的User Agent列入白名單。
- 我們可以找到最常用的User Agents,使用其中之一通常足以繞過基本的防爬措施。這里我們將
zipru_scraper/settings.py文件中的
# Crawl responsibly by identifying yourself (and your website) on the user-agent
#USER_AGENT = 'zipru_scraper (+http://www.yourdomain.com)'
替換為
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'
為了使我們的爬蟲訪問表現(xiàn)得更像人類的操作,讓我們降低請求速率(原理上借助AutoThrottle 拓展),在settings.py中繼續(xù)添加
CONCURRENT_REQUESTS = 1
DOWNLOAD_DELAY = 5
此外,我們的爬蟲還會自動遵守robots.txt,可謂爬蟲界的好公民了?,F(xiàn)在運行 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狀態(tài)碼和一個下載器中間件會自動處理的302重定向響應。不幸的是,302將我們指向一個看上去不詳?shù)逆溄?code>threat_defense.php。毫不意外,爬蟲在那沒找到什么有用的信息,爬取終止了。
下載器中間件
- 在我們深入探索之前了解下Scrapy是如何處理請求和響應的很有幫助。
- 當我們創(chuàng)建基本爬蟲時,生成了
scrapy.Request對象,然后它以某種方式轉(zhuǎn)換為對應服務器響應的scrapy.Response。某種方式中的一大部分是下載器中間件的作用。 - 下載器中間件繼承
scrapy.downloadermiddlewares.DownloaderMiddleware,并且同時實現(xiàn)了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)方法。按照數(shù)字由小到大的順序依次進行,RobotsTxtMiddleware首先處理該請求,HttpCacheMiddleware最后處理。一旦響應被服務器返回,它也會依次經(jīng)歷這些中間件的process_response(request, response, spider)方法。這以相反的順序發(fā)生(數(shù)字大的先處理)。也就是說,數(shù)字越大,越靠近服務器端,數(shù)字越小,越接近爬蟲端。
一個特別簡單的中間件是CookiesMiddleware。它只是簡單地檢查傳入響應的Set-Cookie頭,并且持久化cookies。當傳出請求時,它會適當?shù)卦O置Cookie頭。當然,由于考慮cookie過期等因素它會復雜一點,但是概念是比較清楚的。
另一個相當基礎的中間件是RedirectMiddleware,它只處理3XX重定向。對于所有非3XX狀態(tài)碼的響應它一律放行。當有重定向發(fā)生時,它會由服務器返回的重定向鏈接地址生成一個新的請求。當process_response(request, response, spider)方法返回一個請求對象而不是一個響應對象時,當前的響應對象會被丟棄,然后一切隨著新請求重新開始。之前看到的輸出信息就是如此。
如果你對這么多默認的下載器中間件感興趣,可以查看架構總覽。實際上還有很多其它的東西。但Scrapy偉大的地方在于你不必了解很多,正如不需要知道下載器中間件的存在也能寫出功能足夠的爬蟲,寫出一個工作良好的下載器中間件也不需要你對其它部分的了解。
困難的問題
- 回到我們的小爬蟲,我們發(fā)現(xiàn)我們被重定向到
threat_defense.php?defense=1&...鏈接而得不到想要的頁面。在瀏覽器中查看這個頁面,會是這樣:
在被重定向到threat_defense.php?defense=2&...之前是這樣的:
查看第一個頁面的源碼可以發(fā)現(xiàn),頁面中有一些JavaScript代碼用于構造一個特殊的重定向URL與設置瀏覽器cookies。
我們需要解決這兩個問題,當然也需要識別驗證碼并提交答案。如果我們不小心弄錯了,有時會被重定向到其他的驗證碼頁面,有時我們會終止于這樣的頁面:
我們需要點擊Click here來開始整個重定向周期。小菜一碟,對吧! - 既然所有的問題都來自于一開始的
302重定向,那么順理成章,讓我們在定制的redirect middleware(重定向中間件)里解決它們。在遇到特殊的302重定向到threat_defense.php之外,我們想要這個中間件仍然像普通重定向中間件一樣工作。當它遇到了這個特殊的302,我們希望它繞過所有這些防范措施,為會話添加訪問Cookie,最終請求到原始網(wǎng)頁。如果順利的話,對于我們之前寫的小爬蟲來說,就不需要關心這些細節(jié),像過去一樣正常請求即可。 - 替換
zipru_scraper/middlewares.py中的內(nèi)容為
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):
# 如果沒有特殊的防范性重定向那就正常工作
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 # 防止原始鏈接被標記為重復鏈接
return request
def is_threat_defense_url(self, url):
return '://zipru.to/threat_defense.php' in url
- 我們繼承了
RedirectMiddleware中間件,使得我們可以復用內(nèi)置的重定向處理操作,而只需要將我們的代碼插入_redirect(redirected, request, spider, reason)方法,一旦構建了重定向請求,它會被process_response(request, response, spider)方法調(diào)用。對于非防范性重定向,我們調(diào)用父類的標準方法處理。這里,我們還未實現(xiàn)bypass_threat_defense(url)方法,但是它任務很明確,就是返回訪問cookies,使得我們可以刷新原始請求的cookies,而重新處理原始請求。 - 為了啟用我們的新中間件,要在
zipru_scraper/settings.py中添加如下內(nèi)容:
DOWNLOADER_MIDDLEWARES = {
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': None,
'zipru_scraper.middlewares.ThreatDefenceRedirectMiddleware': 600,
}
它禁用了默認的重定向中間件,并將我們自己的中間件插入到相同的位置。此外,我們還需要安裝一些需求包:
pip install dryscrape # headless webkit 無頭webkit
pip install Pillow # image processing 圖像處理
pip install pytesseract # OCR 字符識別
要注意,這些包都具有pip不能處理的外部依賴。如果安裝出錯,你需要訪問dryscrape,Pillow以及 pytesseract來獲取安裝指導。
- 接下來,我們只需要實現(xiàn)
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支持請求和項目處理的并發(fā),但響應的處理是單線程的。這意味著我們可以使用這個單獨的dryscrape會話,而不用擔心線程安全。
- 現(xiàn)在我們來看一下繞過服務器防御的基本邏輯。
def bypass_threat_defense(self, url=None):
# 有確實的url則訪問
if url:
self.dryscrape_session.visit(url)
# 如果有驗證碼則處理
captcha_images = self.dryscrape_session.css('img[src *= captcha]')
if len(captcha_images) > 0:
return self.solve_captcha(captcha_images[0])
# 點擊可能存在的重試鏈接
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'))
# 否則的話,我們是在一個重定向頁面上,等待重定向后再次嘗試
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)
# 如果url發(fā)生變化則返回
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.')
- 這里我們處理了在瀏覽器訪問時可能遇到的各種情況,并且做了一個正常人類會做出的操作。任何時刻采取的行動取決于當前的頁面,代碼以一種優(yōu)雅的方式順序處理變化的情況。
- 最后一個謎題是解決驗證碼。有不少解決驗證碼服務的API供你在緊要關頭使用,但是這里的驗證碼足夠簡單我們可以用OCR來解決它。使用pytesseract做字符識別,我們最終可以添加
solve_captcha(img)方法來完善我們的bypass_threat_defense()。
def solve_captcha(self, img, width=1280, height=800):
# 對當前頁面截圖
self.dryscrape_session.set_viewport_size(width, height)
filename = tempfile.mktemp('.png')
self.dryscrape_session.render(filename, width, height)
# 注入javascript代碼來找到驗證碼圖片的邊界
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']))
# 解決截圖中的驗證碼
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}"')
# 提交驗證碼結(jié)果
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()
# 如果我們被重定向到一個防御的URL,重試
if self.is_threat_defense_url(self.wait_for_redirect(url)):
return self.bypass_threat_defense()
# 否則就可以返回當前的cookies構成的字典
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
可以看到,如果驗證碼解析失敗,我們會回到bypass_threat_defense()。這樣我們擁有多次嘗試的機會,直到成功一次。
看起來我們的爬蟲應該成功了,可是它陷入了無限循環(huán)中:
[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"
...
看起來我們的中間件至少成功解決了驗證碼,然后重新發(fā)起請求。問題在于新的請求重又觸發(fā)了防御機制。我一開以為bug在解析與添加cookies,可再三檢查無果。這是另一個“唯一可能不同的東西是請求頭”的情況。
Scrapy和dryscrape的請求頭顯然都繞過了觸發(fā)403的第一層過濾器,因為我們沒有得到任何403響應。但這肯定是因為某種請求頭的差異造成的問題。我的猜測是,其中一個加密的訪問Cookie包含了完整的原始訪問請求頭的哈希值,如果兩次請求頭不匹配,將觸發(fā)威脅防御機制。這里的意圖可能是防止某人直接將瀏覽器的cookies復制到爬蟲中,但也只是增加了點小麻煩。
- 所以讓我們在
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,雖然它已經(jīng)被用戶代理中間件自動添加,但是這樣做會便于我們復制請求頭到dryscrape中。下面修改我們的ThreatDefenceRedirectMiddleware的初始化函數(shù)為:
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)
現(xiàn)在scrapy crawl zipru -o torrents.jl命令行運行,成功了!數(shù)據(jù)流不斷涌出!并且都記錄到了我們的torrents.jl文件里。
總結(jié)
- 我們成功應對了
- User agent 過濾
- 模糊的JavaScript重定向
- 驗證碼
- 請求頭一致性檢驗
- 雖然我們的目標網(wǎng)站Zipru是虛構的,但是原理是通用的,希望對你的爬蟲探險有所幫助!



