前一篇文章介紹了很多關于scrapy的進階知識,不過說歸說,隻有在實際應用中才能真正用到這些知識。是以這篇文章就來嘗試利用scrapy爬取各種網站的資料。
爬取百思不得姐
首先一步一步來,我們先從爬最簡單的文本開始。這裡爬取的就是
百思不得姐的的段子,都是文本。
首先打開段子頁面,用F12工具檢視元素。然後用下面的指令打開scrapy shell。
scrapy shell http://www.budejie.com/text/
稍加分析即可得到我們要擷取的資料,在介紹scrapy的第一篇文章中我就寫過一次了。這次就給上次那個爬蟲加上一個翻頁功能。
要擷取的是使用者名和對應的段子,是以在
items.py
中建立一個類。
class BudejieItem(scrapy.Item):
username = scrapy.Field()
content = scrapy.Field()
爬蟲本體就這樣寫,唯一需要注意的就是段子可能分為好幾行,這裡我們要統一合并成一個大字元串。選擇器的
extract()
方法預設會傳回一個清單,哪怕資料隻有一個也是這樣。是以如果資料是單個的,使用
extract_first()
方法。
import scrapy
from scrapy_sample.items import BudejieItem
class BudejieSpider(scrapy.Spider):
"""百思不得姐段子的爬蟲"""
name = 'budejie'
start_urls = ['http://www.budejie.com/text/']
total_page = 1
def parse(self, response):
current_page = int(response.css('a.z-crt::text').extract_first())
lies = response.css('div.j-r-list >ul >li')
for li in lies:
username = li.css('a.u-user-name::text').extract_first()
content = '\n'.join(li.css('div.j-r-list-c-desc a::text').extract())
yield BudejieItem(username=username, content=content)
if current_page < self.total_page:
yield scrapy.Request(self.start_urls[0] + f'{current_page+1}')
導出到檔案
利用scrapy内置的Feed功能,我們可以非常友善的将爬蟲資料導出為XML、JSON和CSV等格式的檔案。要做的隻需要在運作scrapy的時候用
-o
參數指定導出檔案名即可。
scrapy crawl budejie -o f.json
scrapy crawl budejie -o f.csv
scrapy crawl budejie -o f.xml
如果出現導出漢字變成Unicode編碼的話,需要在配置中設定導出編碼。
FEED_EXPORT_ENCODING = 'utf-8'
儲存到MongoDB
有時候爬出來的資料并不想放到檔案中,而是存在資料庫中。這時候就需要編寫管道來處理資料了。一般情況下,爬蟲隻管爬取資料,資料是否重複是否有效都不是爬蟲要關心的事情。清洗資料、驗證資料、儲存資料這些活,都應該交給管道來處理。當然爬個段子的話,肯定是用不到清洗資料這些步驟的。這裡用的是
pymongo
,是以首先需要安裝它。
pip install pymongo
代碼其實很簡單,用scrapy官方文檔的例子稍微改一下就行了。由于MongoDB的特性,是以這部分代碼幾乎是無縫遷移的,如果希望儲存其他資料,隻需要改一下配置就可以了,其餘代碼部分幾乎不需要更改。
import pymongo
class BudejieMongoPipeline(object):
"将百思不得姐段子儲存到MongoDB中"
collection_name = 'jokes'
def __init__(self, mongo_uri, mongo_db):
self.mongo_uri = mongo_uri
self.mongo_db = mongo_db
@classmethod
def from_crawler(cls, crawler):
return cls(
mongo_uri=crawler.settings.get('MONGO_URI'),
mongo_db=crawler.settings.get('MONGO_DATABASE', 'budejie')
)
def open_spider(self, spider):
self.client = pymongo.MongoClient(self.mongo_uri)
self.db = self.client[self.mongo_db]
def close_spider(self, spider):
self.client.close()
def process_item(self, item, spider):
self.db[self.collection_name].insert_one(dict(item))
return item
這個管道需要從配置檔案中讀取資料庫資訊,是以還需要在
settings.py
中增加以下幾行。别忘了在ITEM_PIPELINES中吧我們的管道加進去。
MONGO_URI = 'mongodb://localhost:27017/'
MONGO_DATABASE = 'budejie'
ITEM_PIPELINES = {
'scrapy.pipelines.images.ImagesPipeline': 1,
'scrapy_sample.pipelines.BudejieMongoPipeline': 2
}
最後運作一下爬蟲,應該就可以看到MongoDB中儲存好的資料了。這裡我用的MongoDB用戶端是Studio 3T,我個人覺得比較好用的一個用戶端。
scrapy crawl budejie

MongoDB截圖
儲存到SQL資料庫
原來我基本都是用MySQL資料庫,不過重裝系統之後,我選擇了另一個非常流行的開源資料庫PostgreSQL。這裡就将資料儲存到PostgreSQL中。不過說起來,SQL資料庫确實更加麻煩一些。MongoDB基本上毫無配置可言,一個資料庫、資料集合不需要定義就能直接用,如果沒有就自動建立。而SQL的表需要我們手動建立才行。
首先需要安裝PostgreSQL的Python驅動程式。
pip install Psycopg2
然後建立一個資料庫test和資料表joke。在PostgreSQL中自增主鍵使用
SERIAL
來設定。
CREATE TABLE joke (
id SERIAL PRIMARY KEY,
author VARCHAR(128),
content TEXT
);
管道基本上一樣,隻不過将插入資料換成了SQL形式的。由于預設情況下需要手動調用commit()函數才能送出資料,于是我索性打開了自動送出。
import psycopg2
class BudejiePostgrePipeline(object):
"将百思不得姐段子儲存到PostgreSQL中"
def __init__(self):
self.connection = psycopg2.connect("dbname='test' user='postgres' password='12345678'")
self.connection.autocommit = True
def open_spider(self, spider):
self.cursor = self.connection.cursor()
def close_spider(self, spider):
self.cursor.close()
self.connection.close()
def process_item(self, item, spider):
self.cursor.execute('insert into joke(author,content) values(%s,%s)', (item['username'], item['content']))
return item
别忘了将管道加到配置檔案中。
ITEM_PIPELINES = {
'scrapy.pipelines.images.ImagesPipeline': 1,
'scrapy_sample.pipelines.BudejiePostgrePipeline': 2
}
再次運作爬蟲,就可以看到資料成功的放到PostgreSQL資料庫中了。
pyadmin4截圖
以上就是抽取文本資料的例子了。雖然我隻是簡單的爬了百思不得姐,不過這些方法可以應用到其他方面,爬取更多更有用的資料。這就需要大家探索了。
爬美女圖檔
爬妹子圖網站
說完了抽取文本,下面來看看如何下載下傳圖檔。這裡以
妹子圖為例說明一下。
首先定義一個圖檔Item。scrapy要求圖檔Item必須有image_urls和images兩個屬性。另外需要注意這兩個屬性類型都必須是清單,我就因為沒有将image_urls設定為清單而卡了好幾個小時。
class ImageItem(scrapy.Item):
image_urls = scrapy.Field()
images = scrapy.Field()
然後照例對網站用F12和scrapy shell這兩樣工具進行測試,找出爬取圖檔的方式。這裡我隻是簡單的爬取一個頁面的上的圖檔,不過隻要熟悉了scrapy可以很快的修改成跨越多頁爬取圖檔。再次提醒,爬蟲中生成Item的時候切記image_urls屬性是一個清單,就算隻有一個URL也得是清單。
import scrapy
from scrapy_sample.items import ImageItem
class MeizituSpider(scrapy.Spider):
name = 'meizitu'
start_urls = ['http://www.meizitu.com/a/5501.html']
def parse(self, response):
yield ImageItem(image_urls=response.css('div#picture img::attr(src)').extract())
然後在配置檔案中添加圖檔管道的設定,還需要設定圖檔儲存位置,不然scrapy仍然會禁用圖檔管道。
ITEM_PIPELINES = {
'scrapy.pipelines.images.ImagesPipeline': 1,
}
IMAGES_STORE = 'images'
然後運作爬蟲,就可以看到圖檔已經成功儲存到本地了。
scrapy crawl meizitu
下載下傳好的圖檔
重寫圖檔管道
從上面的圖中我們可以看到檔案名是一堆亂碼字元,因為預設的圖檔管道會将圖檔位址做SHA1哈希之後作為檔案名。如果我們希望自定義檔案名,就需要自己繼承圖檔管道并重寫
file_path
先将預設的
file_path
方法貼出來。
def file_path(self, request, response=None, info=None):
# check if called from image_key or file_key with url as first argument
if not isinstance(request, Request):
url = request
else:
url = request.url
image_guid = hashlib.sha1(to_bytes(url)).hexdigest() # change to request.url after deprecation
return 'full/%s.jpg' % (image_guid)
下面是我們的自定義圖檔管道,這裡擷取圖檔URL的最後一部分作為圖檔檔案名,例如對于
/123.JPG
,就擷取
123.jpg
作為檔案名。
import scrapy.pipelines.images
from scrapy.http import Request
class RawFilenameImagePipeline(scrapy.pipelines.images.ImagesPipeline):
def file_path(self, request, response=None, info=None):
if not isinstance(request, Request):
url = request
else:
url = request.url
beg = url.rfind('/') + 1
end = url.rfind('.')
if end == -1:
return f'full/{url[beg:]}.jpg'
else:
return f'full/{url[beg:end]}.jpg'
如果檔案名生成規則更加複雜,可以參考
znns項目中的
pipeline編寫。他這裡要根據路徑生成多級檔案夾儲存圖檔,是以他的圖檔Item需要額外幾個屬性設定圖檔分類等。這時候就需要重寫get_media_requests方法,從image_urls擷取圖檔位址請求的時候用Request的meta屬性将對應的圖檔Item也傳進去,這樣在生成檔案名的時候就可以讀取meta屬性來确定圖檔的分類等資訊了。
class ZnnsPipeline(ImagesPipeline):
def get_media_requests(self, item, info):
for image_url in item['image_urls']:
yield Request(image_url, meta={'item': item}, headers=headers)
# 這裡把item傳過去,因為後面需要用item裡面的書名和章節作為檔案名
def item_completed(self, results, item, info):
image_paths = [x['path'] for ok, x in results if ok]
if not image_paths:
raise DropItem("Item contains no images")
return item
def file_path(self, request, response=None, info=None):
item = request.meta['item']
image_guid = request.url.split('/')[-1]
filename = u'full/{0[name]}/{0[albumname]}/{1}'.format(item, image_guid)
return filename
最後要說一點,如果不需要使用圖檔管道的幾個功能,完全可以改為使用檔案管道。因為圖檔管道會嘗試将所有圖檔都轉換成JPG格式的,你看源代碼的話也會發現圖檔管道中檔案名類型直接寫死為JPG的。是以如果想要儲存原始類型的圖檔,就應該使用檔案管道。
爬取mm131網站
mm131是另一個圖檔網站,為什麼我要說這個網站呢?因為這個網站使用了防盜鍊技術。對于妹子圖網站來說,由于它沒有防盜鍊功能,是以我們從HTML中擷取的圖檔位址就是實際的圖檔位址。但是對于有反盜鍊的網站來說,當你順着圖檔URL去下載下傳圖檔的時候,會被重定向到一個無關的圖檔。因為這個原因,另外浏覽器有緩存機制導緻我直接通路圖檔位址的時候會先傳回緩存的圖檔,導緻我浪費好幾個小時。最後我重新整理浏覽器的時候才發現原來被重定向了。
對于這種情況,需要我們研究怎樣才能通路到圖檔。
使用Scrapy架構時 普通反爬蟲機制的應對政策這篇文章列舉了一些常見的政策。我們要做的就是根據這些政策進行嘗試。現在我用的是火狐浏覽器,它的F12工具很好用,其中有一個編輯和重發功能可以友善的幫助我們定位問題。
編輯和重發
添加Refer
成功獲得圖檔
在上面幾張圖中,我們可以看到直接嘗試通路圖檔會得到302,然後被重定向到一個騰訊logo上。但是在添加了Referer之後,成功獲得了圖檔。是以問題就是Referer了。這裡簡單介紹一下Referer,它其實是Referrer的誤拼寫。當我們從一個頁面點選進入另一個頁面時,後者的Referer就是前者。是以有些網站就利用Referer做判斷,如果檢測是由另一個網頁進來的,那麼正常通路,如果直接通路圖檔等資源沒有Referer,就判斷為爬蟲,拒絕請求。這種情況下的解決辦法也很簡單,既然網站要Referer,我們手動加上不就行了嗎。
首先,對于圖檔Item,新增一個referer字段,用于儲存該圖檔的Referer。
class ImageItem(scrapy.Item):
image_urls = scrapy.Field()
images = scrapy.Field()
referer = scrapy.Field()
然後在爬蟲裡面,抓取圖檔實際位址的時候,同時設定目前網頁作為Referer。
import scrapy
from scrapy_sample.items import ImageItem
class Mm131Spider(scrapy.Spider):
name = 'mm131'
start_urls = ['http://www.mm131.com/xinggan/3473.html',
'http://www.mm131.com/xinggan/2746.html',
'http://www.mm131.com/xinggan/3331.html']
def parse(self, response):
total_page = int(response.css('span.page-ch::text').extract_first()[1:-1])
current_page = int(response.css('span.page_now::text').extract_first())
item = ImageItem()
item['image_urls'] = response.css('div.content-pic img::attr(src)').extract()
item['referer'] = response.url
yield item
if response.url.rfind('_') == -1:
head, sep, tail = response.url.rpartition('.')
else:
head, sep, tail = response.url.rpartition('_')
if current_page < total_page:
yield scrapy.Request(head + f'_{current_page+1}.html')
最後還需要重寫圖檔管道的get_media_requests方法。我們先來看看圖檔管道基類中是怎麼寫的。self.images_urls_field在這幾行前面設定的,scrapy會嘗試先從配置檔案中讀取自定義的圖檔URL屬性,擷取不到就使用預設的。然後在用圖檔URL屬性從item中擷取url,然後傳遞給Request構造函數組裝為一個Request清單,後續下載下傳器就會用這些請求來下載下傳圖檔。
def get_media_requests(self, item, info):
return [Request(x) for x in item.get(self.images_urls_field, [])]
恰好我們要做的事情很簡單,就是周遊一遍這個Request清單,在每個Request上加上Referer請求頭就行了。是以實際上代碼超級簡單。我們調用基類的實作,也就是上面這個,然後周遊一邊再傳回即可。
class RefererImagePipeline(ImagesPipeline):
def get_media_requests(self, item, info):
requests = super().get_media_requests(item, info)
for req in requests:
req.headers.appendlist("referer", item['referer'])
return requests
最後啟用這個管道。
ITEM_PIPELINES = {
# 'scrapy.pipelines.images.ImagesPipeline': 1,
'scrapy_sample.pipelines.RefererImagePipeline': 2
}
運作一下爬蟲,這次可以看到,成功下載下傳到了一堆圖檔。
scrapy crawl mm131
當然你也可以關掉這個管道,然後運作看看,會發現終端裡一堆重定向錯誤,無法下載下傳圖檔。
這僅僅是一個例子,實際上很多網站可能綜合使用多種技術來檢測爬蟲,這樣我們的爬蟲也需要多種辦法結合來反爬蟲。這個網站恰好隻使用了Referer,是以我們隻用Referer就能解決。
備份CSDN上所有文章
最後一個例子就來爬取CSDN上所有文章,其實在我的scrapy練習中很早就有一個簡單的例子,不過那個是在未登入的情況下擷取所有文章的名字和連結。這裡我要做的是登入CSDN賬号,然後把所有文章爬下來儲存成檔案,也就是示範一下如何用scrapy模拟登入過程。
為什麼要選擇CSDN呢?其實也很簡單,因為現在POST明文使用者名和密碼還不需要驗證碼就能登入的網站真的不多了啊!當然用CSDN的同學也不用怕,雖然CSDN傳遞的是明文密碼,但是由于使用了HTTPS,是以安全性還是可以的。
翻了翻以前寫的文章,發現我确實寫過模拟CSDN登入的文章
Python登入并擷取CSDN部落格所有文章清單,不過運作了一下我發現CSDN頁面經過改版,有些地方變了,是以還是需要重新研究一下。需要注意HTTPS傳輸是不會出現在浏覽器F12工具中的,隻有HTTP傳輸才能在工具中捕獲。是以這時候需要用Fiddler來研究。
不過實際上我又研究了半天,發現其實CSDN登入過程沒變化,我隻要把原來寫的一個多餘的驗證函數删了馬上又可以正常運作了……這裡是我原來的
CSDN模拟登入代碼,用BeautifulSoup4和requests寫的。
又耗費了幾個小時終于把這個爬蟲寫完了,其實編碼過程真的沒費多少時間。主要是由于我對Python語言還是屬于速成的,很多細節沒掌握。比方說scrapy如何用回調方法來分别解析不同頁面、回調方法如何傳遞資料、寫檔案的時候沒有檢查目錄是否存在、檔案應該用什麼模式寫入、如何以UTF-8編碼寫檔案、目錄分隔符如何處理等等,其實都是一些小問題,不過一個一個解決真的廢了我不少事情。
首先,照例定義一個Item,因為我隻準備簡單下載下傳文章,是以隻需要标題和内容兩個屬性即可,标題會作為檔案名來使用。
class CsdnBlogItem(scrapy.Item):
title = scrapy.Field()
content = scrapy.Field()
然後是爬蟲本體,這是我目前寫過的最複雜的一個爬蟲,确實費了不少時間。這個爬蟲跨越了多個頁面,還要針對不同頁面解析不同的資料。不過雖然看着複雜,其實倒是也很簡單。首先是初始方法,從指令行擷取CSDN登入使用者名和密碼,然後存起來備用。由于需要使用者登入,是以parse方法的作用就從解析頁面變成了使用者登入。具體登入過程在我原來那篇文章中詳細解釋過了。這裡就是簡單的利用
FormRequest.from_response
方法将使用者名、密碼以及頁面中的隐藏表單域一起送出。需要注意的就是callback參數,它表示頁面傳回的請求會有另一個方法來處理。
然後是redirect_to_articles方法,本來浏覽器登入成功的話,會傳回一個重定向頁面,浏覽器會執行其中的JS代碼重定向到CSDN頁面。不過我們這是爬蟲,完全沒有執行JS代碼的功能。實際上我們也完全不用在意這個重定向過程,既然登陸成功,有了Cookie,我們想通路什麼頁面都可以。是以這裡同樣直接生成一個新請求通路文章頁面,然後用callback參數指定get_all_articles作為回調。
從get_all_articles方法開始,我們就開始解析頁面了。這個方法首先查詢總共有多少頁,而且由于csdn伺服器是REST形式的,是以我們可以直接将文章頁面基位址和文章頁數拼起來生成所有的頁面。在這些頁面中,每一頁上都有一些文章連結,我們點進去就能通路實際文章了。生成所有頁面的連結之後,我們同樣設定回調,将這些頁面交給parse_article_links方法處理。
import scrapy
from scrapy import FormRequest
from scrapy import Request
from scrapy_sample.items import CsdnBlogItem
class CsdnBlogBackupSpider(scrapy.Spider):
name = 'csdn_backup'
start_urls = ['https://passport.csdn.net/account/login']
base_url = 'http://write.blog.csdn.net/postlist/'
get_article_url = 'http://write.blog.csdn.net/mdeditor/getArticle?id='
def __init__(self, name=None, username=None, password=None, **kwargs):
super(CsdnBlogBackupSpider, self).__init__(name=name, **kwargs)
if username is None or password is None:
raise Exception('沒有使用者名和密碼')
self.username = username
self.password = password
def parse(self, response):
lt = response.css('form#fm1 input[name="lt"]::attr(value)').extract_first()
execution = response.css('form#fm1 input[name="execution"]::attr(value)').extract_first()
eventid = response.css('form#fm1 input[name="_eventId"]::attr(value)').extract_first()
return FormRequest.from_response(
response,
formdata={
'username': self.username,
'password': self.password,
'lt': lt,
'execution': execution,
'_eventId': eventid
},
callback=self.redirect_to_articles
)
def redirect_to_articles(self, response):
return Request(CsdnBlogBackupSpider.base_url, callback=self.get_all_articles)
def get_all_articles(self, response):
import re
text = response.css('div.page_nav span::text').extract_first()
total_page = int(re.findall(r'共(\d+)頁', text)[0])
for i in range(1, total_page + 1):
yield Request(CsdnBlogBackupSpider.base_url + f'0/0/enabled/{i}', callback=self.parse_article_links)
def parse_article_links(self, response):
article_links = response.xpath('//table[@id="lstBox"]/tr[position()>1]/td[1]/a[1]/@href').extract()
last_index_of = lambda x: x.rfind('/')
article_ids = [link[last_index_of(link) + 1:] for link in article_links]
for id in article_ids:
yield Request(CsdnBlogBackupSpider.get_article_url + id, callback=self.parse_article_content)
def parse_article_content(self, response):
import json
obj = json.loads(response.body, encoding='UTF8')
yield CsdnBlogItem(title=obj['data']['title'], content=obj['data']['markdowncontent'])
在parse_article_links方法中,我們擷取每一頁上的所有文章,将文章ID抽出來,然後和這個位址
'http://write.blog.csdn.net/mdeditor/getArticle?id='
拼起來。這是我編輯CSDN文章的時候從浏覽器中抓出來的一個位址,它會傳回一個JSON字元串,包含文章标題、内容、Markdown文本等各種資訊。同樣地,我們用parse_article_content回調函數來處理這個新請求。
下面就是最後一步了,在parse_article_content方法中做的事情很簡單,将JSON字元串轉換成Python對象,然後把我們需要的屬性拿出來。需要交給管道處理的Item對象,也是在這最後一步生成。當然除了用這麼多回調函數來處理,我們還可以在一個函數中手動生成請求并處理響應。
這種通過多個回調函數來處理請求的方式,在編寫複雜的爬蟲中是很常見的。例如我們要爬一個美女圖檔網站,這個網站中每個美女都有好幾個圖集,每個圖集有好幾頁,每頁好幾張圖。如果我們希望按照分類和圖集來生成目錄并儲存,那麼不僅需要多個回調函數來爬取,還需要将圖集、分類等資訊跨越多個回調函數傳遞給最終生成Item的函數。這時候需要利用Request構造函數中的meta屬性,
這裡是一個例子,具體代碼大家自己看就行了。
最後就是文章儲存管道了。這裡沒什麼技術難點,不過讓我這個以前沒弄過這玩意的人來寫,确實費了不少功夫。首先檢測目錄是否存在,如果不存在則建立之。假如目錄不存在的話,open函數就會失敗。然後就是用UTF8編碼儲存文章。
class CsdnBlogBackupPipeline(object):
def process_item(self, item, spider):
dirname = 'blogs'
import os
import codecs
if not os.path.exists(dirname):
os.mkdir(dirname)
with codecs.open(f'{dirname}{os.sep}{item["title"]}.md', 'w', encoding='utf-8') as f:
f.write(item['content'])
f.close()
return item
最後别忘了在配置檔案中啟用管道。
ITEM_PIPELINES = {
# 'scrapy.pipelines.images.ImagesPipeline': 1,
'scrapy_sample.pipelines.CsdnBlogBackupPipeline': 2
}
然後運作一下爬蟲,注意這個爬蟲需要接受額外的使用者名和密碼參數,我們使用
-a
參數來指定。
scrapy crawl csdn_backup -a username="使用者名" -a password="密碼"
備份文章成功
這裡說一下,我現在改為使用簡書來編寫文章,一來是由于簡書的體驗确實相比來說非常好,在編輯器中可以直接粘貼并自動上傳剪貼闆中的圖檔;二來因為簡書圖檔沒有外鍊限制,是以Markdown文本可以直接複制到其他網站中,同時維護多個部落格非常容易,如果有同時關注我CSDN和簡書的同學也會發現,很多文章我的送出時間基本隻差了十幾秒,這就是複制粘貼所用的時間。包括剛剛爬下來的文章,隻要在Markdown編輯器中打開,圖檔都可以正常通路。
以上就是我備份CSDN上文章的一個簡單例子,說它簡單因為真的沒幹什麼事情,單純的把文章内容爬下來而已,其中的圖檔存儲仍然依賴于簡書和其他網站來儲存。有興趣的同學可以嘗試做更完善的備份功能,将每篇文章按目錄儲存,文章中的圖檔按照各自的目錄下載下傳到本地,并将Markdown文本中對應圖檔的位址由伺服器替換為本地路徑。把這些功能全做完,就是一個真正的文章備份工具了。由于水準所限,我就不做了。
這篇文章到這裡也該結束了,雖然隻有4個例子,但是我嘗試涵蓋爬蟲的所有應用場景、爬取圖檔、爬取文本、儲存到資料庫和檔案、自定義管道等等。希望這篇文章對大家能有所幫助!這些代碼全在我的
Github上,歡迎關注。