漫談Pyspider網絡爬蟲的實踐

感覺很久沒有寫點東西了,因為最近太忙(外因)或是自身太懶(內因)的原因。總之,很早之前,我就開始規劃著寫點關于網絡爬蟲方面的文章,介紹性質的,但更重要的是,計算機以及信息科學的實踐性,所以,以一個實干者的角度來寫,更為合適一些。
在這之前,還是有必要對一些概念性的詞匯做一下梳理和科普,至少,不會讓讀者覺得突兀或者一知半解的讀著流水賬式的文字。

什么是網絡爬蟲

來一段靠譜的維基百科的權威解釋

網絡蜘蛛(Web spider)也叫網絡爬蟲(Web crawler),螞蟻(ant),自動檢索工具(automatic indexer),或者(在FOAF軟件概念中)網絡疾走(WEB scutter),是一種“自動化瀏覽網絡”的程序,或者說是一種網絡機器人。它們被廣泛用于互聯網搜索引擎或其他類似網站,以獲取或更新這些網站的內容和檢索方式。它們可以自動采集所有其能夠訪問到的頁面內容,以供搜索引擎做進一步處理(分檢整理下載的頁面),而使得用戶能更快的檢索到他們需要的信息。

所以網絡爬蟲是一種自動化運行的獲取目標網頁信息內容并按一定結構存儲的便于查詢檢索的計算機程序。

這段話似乎讓人看了很暈很懵逼,這個什么鬼的到底能做啥呢?
舉幾個很通俗的例子吧,比如你一手掌握了很多看美女圖片和小電影的網站和論壇(這里都指的線上的,網絡上的資源),每當夜深人靜的時候,你偷偷打開電腦,秒開瀏覽器那個只有你本人知道的收藏夾,然后一頓點看擼晃,但是,等等,其實真實的體驗沒這么流暢?

  • 信息太散了,每個資源都需要單獨去訪問,浪費時間的點擊
  • 資源良莠不齊,口味繁雜,需要花時間找到自己喜歡的內容
  • 大量煩人的廣告,這尼瑪是廣告還是馬賽克?還是播放?還是下載?
  • 手動找更新,又是一件繁重的工作

所以想看更純粹更專注的內容,自己動手,寫爬蟲吧。

你說我沒這需求,那沒關系,你總需要看新聞吧,看文學吧,逛電商吧,看股票吧,總有那么一類信息,你會關注的,但是現有的服務總是不夠單純,界面不夠友好,信息不夠純粹,所以,這是你需要學習并實踐網絡爬蟲技術的動機。

為什么是Python

寫網絡爬蟲的語言有很多,編程的語言更多。個人認為Python是一種工具型的語言,上手快,語法簡單(相比于C/C++/JAVA族),各種功能庫豐富而且小巧單一(每個獨立的庫只做一件事情),所以編程就像是在玩樂高積木,照著自己設計好的流程,拼接就行了。當然,這是筆者個人的經驗和喜好。如果你有自己擅長并喜歡的,大可用自己的去實現一個網絡爬蟲系統,這個不在本文的討論范圍之類了。
有關幾種編程語言編寫網絡爬蟲的比較,可以參考知乎上的文章 PHP, Python, Node.js 哪個比較適合寫爬蟲?

為什么是Pyspider

Python有很多成熟的網絡爬蟲框架, 知乎上很多大牛總結了一些實踐經驗,具體可以參考如何入門 Python 爬蟲?
很多推薦用requests做請求,query/soup做頁面數據(Html/Xml)解析,看起來很靈活,然而,一個比較完善的網絡爬蟲系統,所需要提供的功能可能遠遠不止這些。也有推薦Scrapy的,雖然看起來功能非常強大,但是這個框架上手需要一些時間,有一定的學習成本,相對于新手來說,很難快速專注爬蟲業務的開發。
Pyspider是Roy Binux開發的一款開源的網絡爬蟲系統,它不止是一個爬蟲框架,而是一套完備的爬蟲系統,使用這套系統你只需要關注兩件事情

  • 目標網站上的內容元素的解析,而且只需要關注解析什么,解析框架也有提供,并且提供了可視化工具輔助從目標頁面摳取需要解析的元素CSS屬性
  • 解析出來的內容元素如何保存,你只需要關注數據庫表字段的設計,然后把解析出來的頁面元素內容保存到數據庫表中
  • 那么,剩下的幾乎所有事情,就交給Pyspider吧

是不是聽上去感覺很簡單,那么,開始動手吧,跟著這篇官方文檔,最快幾分鐘的功夫,你就可以學會從2048(草榴)找到真愛了。

簡單的爬取看官方文檔就可以了,不過,實踐過程中總會遇到各種問題,那么,看看這些如何解決的吧。

如何模擬登陸

有些網站內容的展示需要用戶登錄,那么如果需要爬取這樣的頁面內容,我們的爬蟲就需要模擬用戶登陸。網站一般在頁面跳轉或者刷新的時候,也需要獲取登錄信息以確定這個頁面的訪問用戶是登陸過的。如果每次都需要用戶重新登錄,那么這種體驗就太爛了,需要一種機制把之前用戶登陸的信息保存起來,而且一定是保存在瀏覽器可以訪問的本地存儲上,這樣,用戶在頁面跳轉或者頁面刷新的時候,登錄信息被網站自動讀取,就不需要用戶頻繁登錄了。而這個保存的地方,叫做Cookie。
爬蟲需要做的事情,一是模擬登陸,拿到Cookie數據,然后保存下來,二是每次去訪問網頁的時候,將Cookie信息傳遞給請求,這樣就可以正常爬到需要用戶登錄的數據了。

我們先設計一個登錄類,用來管理登錄的請求和數據

import urllib
import urllib2
import lxml.html as HTML

class Login(object):

    def __init__(self, username, password, login_url, post_url_prefix):
        self.username = username
        self.password = password
        self.login_url = login_url
        self.post_url_prefix = post_url_prefix

    def login(self):
        post_url, post_data = self.getPostData()
        post_url = self.post_url_prefix + post_url
        req = urllib2.Request(url = post_url, data = post_data)
        resp = urllib2.urlopen(req)
        return True

    def getPostData(self):
        url = self.login_url.strip()
        if not re.match(r'^http://', url):
            return None, None
        req = urllib2.Request(url)
        resp = urllib2.urlopen(req)
        login_page = resp.read()
        doc = HTML.fromstring (login_page)
        post_url = doc.xpath("http://form[@method='post' and @id='lsform']/@action")[0]
        cookietime = doc.xpath("http://input[@name='cookietime' and @id='ls_cookietime']/@value")[0]
        username = self.username
        password = self.password
        post_data = urllib.urlencode({
            'fastloginfield' : 'username',
            'username' : username,
            'password' : password,
            'quickforward' : 'no',
            'handlekey' : 'ls',
            'cookietime' : cookietime,
        })
        return post_url, post_data

代碼解釋

  • 用戶名username, 密碼password, 目標網站的登錄頁面地址login_url, 目標網站的主域名post_url_prefix,這些參數從外部傳入,目標網站的登錄頁面地址也有可能就是網站的主頁地址。
  • getPostData首先向目標網站的登錄頁面地址發起一個請求,然后解析這個頁面的數據,解析出登錄請求的目標地址和post請求的數據(登錄請求一般為post請求),然后返回這兩個參數

設計一個方法,這個方法用來獲取爬取網頁請求需要的Cookie數據。

import os
import hashlib
import cookielib

LOGIN_URL = 'http://登錄頁面地址'
USER_NAME = '用戶名'
PASSWORD = '密碼'

HOST = '目標網頁主域名'
REFERER = 'http://目標網頁主域名/'
POST_URL_PREFIX = 'http://目標網頁主域名/'

# !!! Notice !!!
# Tasks that share the same account MUST share the same cookies file
COOKIES_FILE = '/tmp/pyspider.%s.%s.cookies' % (HOST, hashlib.md5(USER_NAME).hexdigest())
COOKIES_DOMAIN = HOST

USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36'
HTTP_HEADERS = {
    'Accept' : 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
    'Accept-Encoding' : 'gzip, deflate, sdch',
    'Accept-Language' : 'zh-CN,zh;q=0.8,en;q=0.6',
    'Connection' : 'keep-alive',
    'DNT' : '1',
    'Host' : HOST,
    'Referer' : REFERER,
    'User-Agent' : USER_AGENT,
}

def getCookies():
    cookiesJar = cookielib.MozillaCookieJar(COOKIES_FILE)
    if not os.path.isfile(COOKIES_FILE):
        cookiesJar.save()
    cookiesJar.load (COOKIES_FILE)
    cookieProcessor = urllib2.HTTPCookieProcessor(cookiesJar)
    cookieOpener = urllib2.build_opener(cookieProcessor, urllib2.HTTPHandler)
    for item in HTTP_HEADERS:
        cookieOpener.addheaders.append ((item ,HTTP_HEADERS[item]))
    urllib2.install_opener(cookieOpener)
    if len(cookiesJar) == 0:
        login = Login(USER_NAME, PASSWORD, LOGIN_URL, POST_URL_PREFIX)
        if login.login():
            cookiesJar.save()
        else:
            return None
    cookiesDict = {}
    for cookie in cookiesJar:
        if COOKIES_DOMAIN in cookie.domain:
            cookiesDict[cookie.name] = cookie.value
    return cookiesDict

代碼解釋

  • USER_NAME PASSWORD LOGIN_URL POST_URL_PREFIX 分別定義了用戶名/密碼/登錄頁面地址/目標網頁前綴
  • 如果從COOKIES_FILE讀取出的Cookie信息為空,那么就調用Login做登錄流程,并且把獲取到的結果保存,如果Cookie不為空,就返回Cookie信息到字典cookiesDict中

Pyspider每次爬取請求都帶上Cookie字典,這樣,向目標地址發請求就可以獲取到需要登錄才能訪問到的數據了。

cookies = getCookies()
self.crawl(url, cookies = cookies, callback=self.index_page)

如何解析爬取下來的內容

爬取的內容通過回調的參數response返回,response有多種解析方式

  • 如果返回的數據是json,則可以通過response.json訪問
  • response.doc返回的是PyQuery對象
  • response.etree返回的是lxml對象
  • response.text返回的是unicode文本
  • response.content返回的是字節碼

所以返回數據可以是5種形式,unicode和字節碼不是結構化的數據,很難解析,這里就不贅述了,json需要特定的條件,而且解析相對簡單,也不必說。
常用的就是PyQuery和lxml的方式,關于lxml,可以采用XPath的語法來解析,比如前面模擬登錄中就采用了xpath的語法解析網頁,具體可參考lxml和XPath的相關文檔

XPath選擇器參考

選擇器 示例 示例說明
nodename bookstore 選擇所有名稱叫做"bookstore"的節點
/ bookstore/book 選擇"bookstore"的節點的所有"book"子節點
// //book 選擇文檔中所有名稱叫做"book"的節點,不管它們的父節點叫做什么
. 選擇當前的節點
.. 選擇當前節點的父節點
@ //@lang 選擇所有名稱叫做"lang"的屬性
bookstore//book 選擇節點"bookstore"所有叫做"book"的子孫節點,bookstore不一定是book的父節點
/bookstore/book[1] 選擇節點"bookstore"的第一個叫做"book"的子節點
/bookstore/book[last()] 選擇節點"bookstore"的最后一個叫做"book"的子節點
//title[@lang] 選擇所有有一個屬性名叫做"lang"的title節點
//title[@lang='en'] 選擇所有有一個屬性"lang"的值為"en"的title節點
* /bookstore/* 選擇"bookstore"節點的所有子節點
//* 選擇文檔中所有的節點
@* //title[@*] 選擇所有的"title"節點至少含有一個屬性,屬性名稱不限

PyQuery可以采用CSS選擇器作為參數對網頁進行解析。
類似這樣

response.doc('.ml.mlt.mtw.cl > li').items()

或者這樣

response.doc('.pti > .pdbt > .authi > em > span').attr('title')

關于PyQuery更多玩法,可以參考PyQuery complete API

CSS選擇器

選擇器 示例 示例說明
.class .intro Selects all elements with class="intro"
#id #firstname Selects the element with id="firstname"
element p Selects all <p> elements
element,element div, p Selects all <div> elements and all <p> elements
element element div p Selects all <p> elements inside <div> elements
element>element div > p Selects all <p> elements where the parent is a <div> element
[attribute] [target] Selects all elements with a target attribute
[attribute=value] [target=_blank] Selects all elements with target="_blank"
[attribute^=value] a[href^="https"] Selects every <a> element whose href attribute value begins with "https"
[attribute$=value] a[href$=".pdf"] Selects every <a> element whose href attribute value ends with ".pdf"
[attribute*=value] a[href*="w3schools"] Selects every <a> element whose href attribute value contains the substring "w3schools"
:checked input:checked Selects every checked <input> element

更多詳情請參考CSS Selector Reference

如何將數據保存到MySQL中

將MySQL的數據庫訪問封裝成一個類

import hashlib
import unicodedata
import mysql.connector
from mysql.connector import errorcode

class MySQLDB:
    
    username = '數據庫用戶名'
    password = '數據庫密碼'
    database = '數據庫名'
    host = 'localhost'    #數據庫主機地址
    connection = ''
    isconnect = True
    placeholder = '%s'
    
    def __init__(self):
        if self.isconnect:
            MySQLDB.connect(self)
            MySQLDB.initdb(self)
    
    def escape(self,string):
        return '`%s`' % string
    
    def connect(self):
        config = {
            'user':self.username,
            'password':self.password,
            'host':self.host
        }
        
        if self.database != None:
            config['database'] = self.database
    
        try:
            cnx = mysql.connector.connect(**config)
            self.connection = cnx
            return True
        except mysql.connector.Error as err:
            if (err.errno == errorcode.ER_ACCESS_DENIED_ERROR):
                print "The credentials you provided are not correct."
            elif (err.errno == errorcode.ER_BAD_DB_ERROR):
                print "The database you provided does not exist."
            else:
                print "Something went wrong: " , err
            return False
        
    def initdb(self):
        if self.connection == '':
            print "Please connect first"
            return False
        cursor = self.connection.cursor()
        
        # 創建表的定義
        sql = 'CREATE TABLE IF NOT EXISTS \
        table_name ( \
        id VARCHAR(64) PRIMARY KEY, \
        url TEXT, \
        title TEXT, \
        type TEXT, \
        thumb TEXT, \
        count INTEGER, \
        images TEXT, \
        tags TEXT, \
        post_time DATETIME \
        ) ENGINE=INNODB DEFAULT CHARSET=UTF8'
        try:
            cursor.execute(sql)
            self.connection.commit()
            return True
        except mysql.connector.Error as err:
            print ("An error occured: {}".format(err))
            return False
        
    def cleardb (self):
        if self.connection == '':
            print "Please connect first"
            return False
        cursor = self.connection.cursor()
        sql = 'DROP TABLE IF EXISTS table_name'
        try:
            cursor.execute(sql)
            self.connection.commit()
            return True
        except mysql.connector.Error as err:
            print ("An error occured: {}".format(err))
            return False
        
    def insert (self,**values):
        if self.connection == '':
            print "Please connect first"
            return False
        cursor = self.connection.cursor()
        # 插入數據
        sql = "insert into table_name (id, url, title, type, thumb, count, temperature, images, tags, post_time) values (%s,%s,%s,%s,%s,%s,%s,%s,%s) on duplicate key update id=VALUES(id), url=VALUES(url), title=VALUES(title), type=VALUES(type), thumb=VALUES(thumb), count=VALUES(count), images=VALUES(images), tags=VALUES(tags), post_time=VALUES(post_time)"
        title = unicodedata.normalize('NFKD', values['title']).encode('ascii','ignore')
        images = ", ".join('%s' % k for k in values['images'])
        params = (hashlib.md5(title + images).hexdigest(), values['url'], values['title'], values['type'], values['thumb'], values['count'], images, '', values['date'])
        try:
            cursor.execute(sql,params)
            self.connection.commit()
            return True
        except mysql.connector.Error as err:
            print ("An error occured: {}".format(err))
            return False
    
    def replace(self,tablename=None,**values):
        if self.connection == '':
            print "Please connect first"
            return False
    
        tablename = self.escape(tablename)
        if values:
            _keys = ", ".join(self.escape(k) for k in values)
            _values = ", ".join([self.placeholder, ] * len(values))
            sql_query = "REPLACE INTO %s (%s) VALUES (%s)" % (tablename, _keys, _values)
        else:
            sql_query = "REPLACE INTO %s DEFAULT VALUES" % tablename
                
        cur = self.connection.cursor()
        try:
            if values:
                cur.execute(sql_query, list(itervalues(values)))
            else:
                cur.execute(sql_query)
            self.connection.commit()
            return True
        except mysql.connector.Error as err:
            print ("An error occured: {}".format(err))
            return False

在處理爬取結果的回調中保存到數據庫

def on_result(self, result):
        db = MySQLDB()
        db.insert(**result)

如何在爬蟲腳本更新后重新運行之前執行過的任務

比如這種場景,爬取了一些數據,發現沒有寫保存到數據庫的邏輯,然后加上了這段邏輯,卻發現之前跑過的任務不會在執行了。那么如何做到在爬蟲腳本改動后,之前的任務重新自動再跑一遍呢。
crawl_config中使用itag來標示爬蟲腳本的版本號,如果這個值發生改變,那么所有的任務都會重新再跑一遍。示例代碼如下

class Handler(BaseHandler):
    crawl_config = {
        'headers': {
            'User-Agent': USER_AGENT,
        },
        'itag': 'v1'
    }

itag也可以用來控制特定的任務是否需要重新執行,詳見官方文檔

如何解析JavaScript代碼

具體如何使用的可以看官方文檔,這里列舉出一些可供參考的JavaScript解析器
基于WebkitPhantomJS
基于GeckoSlimerJS
基于PhantomJSSlimerJSCasperJS
Nightmare
Selenium
spynner
ghost.py

更多工具/框架請參考Headless Browser and scraping - solutions

參考資料

binux/pyspider
Pyspider官方文檔
pyspider架構設計
pyspider中文腳本編寫指南
Pyspider爬蟲教程
把 pyspider的結果存入自定義的mysql數據庫中
pyspider的mysql數據存儲接口
PyQuery complete API
CSS Selector Reference

收集的一些其它網絡爬蟲的資料

雪球股票信息超級爬蟲

烏云公開漏洞、知識庫爬蟲和搜索

Scrapy

爬視頻音頻神器You-Get

另一款視頻下載神器youtube-dl
youtube-dl圖形界面版

crawley

PHP Crawler
PHPCrawl
Phpfetcher

想了解更多的內容,請點擊閱讀原文

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • HTTP基本原理 URI、URL、URN(Uninform Resource) URI(Identifier):統...
    GHope閱讀 2,118評論 2 26
  • 1 前言 作為一名合格的數據分析師,其完整的技術知識體系必須貫穿數據獲取、數據存儲、數據提取、數據分析、數據挖掘、...
    whenif閱讀 18,103評論 45 523
  • 10年前的今天 23歲 來到一個會發生很多故事的地方 2010.12.8,那一天的佛教故事與釋迦摩尼有關 灰黑陰冷...
    陳杉豈閱讀 86評論 0 0
  • 17年底,楊總發信息給老婆,問公司的6萬股權,是否贖回。老婆問我意見,我說反正也不用錢,放著吧,就這么回復的老楊。...
    833d2e0dc676閱讀 367評論 0 2