網(wǎng)絡爬蟲Scrapy從入門到進階

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 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>
    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é)

  • 我們成功應對了
    1. User agent 過濾
    2. 模糊的JavaScript重定向
    3. 驗證碼
    4. 請求頭一致性檢驗
  • 雖然我們的目標網(wǎng)站Zipru是虛構的,但是原理是通用的,希望對你的爬蟲探險有所幫助!
最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容