爬蟲的兩部分,一是下載 Web 頁面,有許多問題須要考慮,如何最大程度地利用本地帶寬,如何調度針對不一樣站點的 Web 請求以減輕對方服務器的負擔等。一個高性能的 Web Crawler 系統裏,DNS 查詢也會成爲急需優化的瓶頸,另外,還有一些「行規」須要遵循(例如 robots.txt)。而獲取了網頁以後的分析過程也是很是複雜的,Internet 上的東西千奇百怪,各類錯誤百出的 HTML 頁面都有,要想所有分析清楚幾乎是不可能的事;另外,隨着 AJAX 的流行,如何獲取由 Javascript 動態生成的內容成了一大難題;除此以外,Internet 上還有有各類有意或無心出現的Spider Trap ,若是盲目的跟蹤超連接的話,就會陷入 Trap 中萬劫不復了,例如這個網站,聽說是以前 Google 宣稱 Internet 上的 Unique URL 數目已經達到了 1 trillion 個,所以這我的 is proud to announce the second trillion 。 html
不過,其實並無多少人須要作像 Google 那樣通用的 Crawler ,一般咱們作一個 Crawler 就是爲了去爬特定的某個或者某一類網站,所謂知己知彼,百戰不殆,咱們能夠事先對須要爬的網站結構作一些分析,事情就變得容易多了。經過分析,選出有價值的連接進行跟蹤,就能夠避免不少沒必要要的連接或者 Spider Trap ,若是網站的結構容許選擇一個合適的路徑的話,咱們能夠按照必定順序把感興趣的東西爬一遍,這樣以來,連 URL 重複的判斷也能夠省去。python
舉個例子,假如咱們想把 pongba 的 blog mindhacks.cn 裏面的 blog 文字爬下來,經過觀察,很容易發現咱們對其中的兩種頁面感興趣:linux
- 文章列表頁面,例如首頁,或者 URL 是
/page/\d+/
這樣的頁面,經過 Firebug 能夠看到到每篇文章的連接都是在一個h1
下的a
標籤裏的(須要注意的是,在 Firebug 的 HTML 面板裏看到的 HTML 代碼和 View Source 所看到的也許會有些出入,若是網頁中有 Javascript 動態修改 DOM 樹的話,前者是被修改過的版本,而且通過 Firebug 規則化的,例如 attribute 都有引號擴起來等,然後者一般纔是你的 spider 爬到的原始內容。若是是使用正則表達式對頁面進行分析或者所用的 HTML Parser 和 Firefox 的有些出入的話,須要特別注意),另外,在一個 class 爲wp-pagenavi
的div
裏有到不一樣列表頁面的連接。 - 文章內容頁面,每篇 blog 有這樣一個頁面,例如 /2008/09/11/machine-learning-and-ai-resources/ ,包含了完整的文章內容,這是咱們感興趣的內容。
所以,咱們從首頁開始,經過 wp-pagenavi
裏的連接來獲得其餘的文章列表頁面,特別地,咱們定義一個路徑:只 follow Next Page 的連接,這樣就能夠從頭至尾按順序走一遍,免去了須要判斷重複抓取的煩惱。另外,文章列表頁面的那些到具體文章的連接所對應的頁面就是咱們真正要保存的數據頁面了。正則表達式
這樣以來,其實用腳本語言寫一個 ad hoc 的 Crawler 來完成這個任務也並不難,不過今天的主角是 Scrapy ,這是一個用 Python 寫的 Crawler Framework ,簡單輕巧,而且很是方便,而且官網上說已經在實際生產中在使用了,所以並非一個玩具級別的東西。不過如今尚未 Release 版本,能夠直接使用他們的 Mercurial 倉庫裏抓取源碼進行安裝。不過,這個東西也能夠不安裝直接使用,這樣還方便隨時更新,文檔裏說得很詳細,我就不重複了。sql
Scrapy 使用 Twisted 這個異步網絡庫來處理網絡通信,架構清晰,而且包含了各類中間件接口,能夠靈活的完成各類需求。總體架構如下圖所示:shell
綠線是數據流向,首先從初始 URL 開始,Scheduler 會將其交給 Downloader 進行下載,下載以後會交給 Spider 進行分析,Spider 分析出來的結果有兩種:一種是須要進一步抓取的連接,例如以前分析的「下一頁」的連接,這些東西會被傳回 Scheduler ;另外一種是須要保存的數據,它們則被送到 Item Pipeline 那裏,那是對數據進行後期處理(詳細分析、過濾、存儲等)的地方。另外,在數據流動的通道里還能夠安裝各類中間件,進行必要的處理。數據庫
看起來好像很複雜,其實用起來很簡單,就如同 Rails 同樣,首先新建一個工程:服務器
scrapy-admin.py startproject blog_crawl |
會建立一個 blog_crawl
目錄,裏面有個 scrapy-ctl.py
是整個項目的控制腳本,而代碼全都放在子目錄 blog_crawl
裏面。爲了能抓取 mindhacks.cn ,咱們在 spiders
目錄裏新建一個mindhacks_spider.py
,定義咱們的 Spider 以下:網絡
from scrapy.spider import BaseSpider class MindhacksSpider(BaseSpider): domain_name = "mindhacks.cn" start_urls = ["http://mindhacks.cn/"] def parse(self, response): return [] SPIDER = MindhacksSpider() |
咱們的 MindhacksSpider
繼承自 BaseSpider
(一般直接繼承自功能更豐富的scrapy.contrib.spiders.CrawlSpider
要方便一些,不過爲了展現數據是如何 parse 的,這裏仍是使用 BaseSpider
了),變量 domain_name
和 start_urls
都很容易明白是什麼意思,而 parse
方法是咱們須要定義的回調函數,默認的 request 獲得 response 以後會調用這個回調函數,咱們須要在這裏對頁面進行解析,返回兩種結果(須要進一步 crawl 的連接和須要保存的數據),讓我感受有些奇怪的是,它的接口定義裏這兩種結果居然是混雜在一個 list 裏返回的,不太清楚這裏爲什麼這樣設計,難道最後不仍是要費力把它們分開?總之這裏咱們先寫一個空函數,只返回一個空列表。另外,定義一個「全局」變量 SPIDER
,它會在 Scrapy 導入這個 module 的時候實例化,並自動被 Scrapy 的引擎找到。這樣就能夠先運行一下 crawler 試試了:架構
./scrapy-ctl.py crawl mindhacks.cn |
會有一堆輸出,能夠看到抓取了 http://mindhacks.cn
,由於這是初始 URL ,可是因爲咱們在 parse
函數裏沒有返回須要進一步抓取的 URL ,所以整個 crawl 過程只抓取了主頁便結束了。接下來即是要對頁面進行分析,Scrapy 提供了一個很方便的 Shell (須要 IPython )可讓咱們作實驗,用以下命令啓動 Shell :
./scrapy-ctl.py shell http://mindhacks.cn |
它會啓動 crawler ,把命令行指定的這個頁面抓取下來,而後進入 shell ,根據提示,咱們有許多現成的變量能夠用,其中一個就是 hxs
,它是一個 HtmlXPathSelector
,mindhacks 的 HTML 頁面比較規範,能夠很方便的直接用 XPath 進行分析。經過 Firebug 能夠看到,到每篇 blog 文章的連接都是在 h1
下的,所以在 Shell 中使用這樣的 XPath 表達式測試:
In [1]: hxs.x('//h1/a/@href').extract() Out[1]: [u'http://mindhacks.cn/2009/07/06/why-you-should-do-it-yourself/', u'http://mindhacks.cn/2009/05/17/seven-years-in-nju/', u'http://mindhacks.cn/2009/03/28/effective-learning-and-memorization/', u'http://mindhacks.cn/2009/03/15/preconception-explained/', u'http://mindhacks.cn/2009/03/09/first-principles-of-programming/', u'http://mindhacks.cn/2009/02/15/why-you-should-start-blogging-now/', u'http://mindhacks.cn/2009/02/09/writing-is-better-thinking/', u'http://mindhacks.cn/2009/02/07/better-explained-conflicts-in-intimate-relationship/', u'http://mindhacks.cn/2009/02/07/independence-day/', u'http://mindhacks.cn/2009/01/18/escape-from-your-shawshank-part1/'] |
這正是咱們須要的 URL ,另外,還能夠找到「下一頁」的連接所在,連同其餘幾個頁面的連接一同在一個 div
裏,不過「下一頁」的連接沒有 title
屬性,所以 XPath 寫做
//div[@class="wp-pagenavi"]/a[not(@title)] |
不過若是向後翻一頁的話,會發現其實「上一頁」也是這樣的,所以還須要判斷該連接上的文字是那個下一頁的箭頭 u'\xbb'
,原本也能夠寫到 XPath 裏面去,可是好像這個自己是 unicode escape 字符,因爲編碼緣由理不清楚,直接放到外面判斷了,最終 parse
函數以下:
def parse(self, response): items = [] hxs = HtmlXPathSelector(response) posts = hxs.x('//h1/a/@href').extract() items.extend([self.make_requests_from_url(url).replace(callback=self.parse_post) for url in posts]) page_links = hxs.x('//div[@class="wp-pagenavi"]/a[not(@title)]') for link in page_links: if link.x('text()').extract()[0] == u'\xbb': url = link.x('@href').extract()[0] items.append(self.make_requests_from_url(url)) return items |
前半部分是解析須要抓取的 blog 正文的連接,後半部分則是給出「下一頁」的連接。須要注意的是,這裏返回的列表裏並非一個個的字符串格式的 URL 就完了,Scrapy 但願獲得的是Request
對象,這比一個字符串格式的 URL 能攜帶更多的東西,諸如 Cookie 或者回調函數之類的。能夠看到咱們在建立 blog 正文的 Request
的時候替換掉了回調函數,由於默認的這個回調函數 parse
是專門用來解析文章列表這樣的頁面的,而 parse_post
定義以下:
def parse_post(self, response): item = BlogCrawlItem() item.url = unicode(response.url) item.raw = response.body_as_unicode() return [item] |
很簡單,返回一個 BlogCrawlItem
,把抓到的數據放在裏面,原本能夠在這裏作一點解析,例如,經過 XPath 把正文和標題等解析出來,可是我傾向於後面再來作這些事情,例如 Item Pipeline 或者更後面的 Offline 階段。BlogCrawlItem
是 Scrapy 自動幫咱們定義好的一個繼承自ScrapedItem
的空類,在 items.py
中,這裏我加了一點東西:
from scrapy.item import ScrapedItem class BlogCrawlItem(ScrapedItem): def __init__(self): ScrapedItem.__init__(self) self.url = '' def __str__(self): return 'BlogCrawlItem(url: %s)' % self.url |
定義了 __str__
函數,只給出 URL ,由於默認的 __str__
函數會把全部的數據都顯示出來,所以會看到 crawl 的時候控制檯 log 狂輸出東西,那是把抓取到的網頁內容輸出出來了。-.-bb
這樣一來,數據就取到了,最後只剩下存儲數據的功能,咱們經過添加一個 Pipeline 來實現,因爲 Python 在標準庫裏自帶了 Sqlite3 的支持,因此我使用 Sqlite 數據庫來存儲數據。用以下代碼替換 pipelines.py 的內容:
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 |
import sqlite3 from os import path from scrapy.core import signals from scrapy.xlib.pydispatch import dispatcher class SQLiteStorePipeline(object): filename = 'data.sqlite' def __init__(self): self.conn = None dispatcher.connect(self.initialize, signals.engine_started) dispatcher.connect(self.finalize, signals.engine_stopped) def process_item(self, domain, item): self.conn.execute('insert into blog values(?,?,?)', (item.url, item.raw, unicode(domain))) return item def initialize(self): if path.exists(self.filename): self.conn = sqlite3.connect(self.filename) else: self.conn = self.create_table(self.filename) def finalize(self): if self.conn is not None: self.conn.commit() self.conn.close() self.conn = None def create_table(self, filename): conn = sqlite3.connect(filename) conn.execute("""create table blog (url text primary key, raw text, domain text)""") conn.commit() return conn |
在 __init__
函數中,使用 dispatcher 將兩個信號鏈接到指定的函數上,分別用於初始化和關閉數據庫鏈接(在 close
以前記得 commit
,彷佛是不會自動 commit
的,直接 close
的話好像全部的數據都丟失了 dd-.-)。當有數據通過 pipeline 的時候,process_item
函數會被調用,在這裏咱們直接講原始數據存儲到數據庫中,不做任何處理。若是須要的話,能夠添加額外的 pipeline ,對數據進行提取、過濾等,這裏就不細說了。
最後,在 settings.py
裏列出咱們的 pipeline :
ITEM_PIPELINES = ['blog_crawl.pipelines.SQLiteStorePipeline'] |
再跑一下 crawler ,就 OK 啦! 最後,總結一下:一個高質量的 crawler 是極其複雜的工程,可是若是有好的工具的話,作一個專用的 crawler 仍是比較容易的。Scrapy 是一個很輕便的爬蟲框架,極大地簡化了 crawler 開發的過程。另外,Scrapy 的文檔也是十分詳細的,若是以爲個人介紹省略了一些東西不太清楚的話,推薦看他的 Tutorial 。