作者: Mask
來自:https://segmentfault.com/a/1190000015880780
遇到的需求
前段時間需要快速做個靜態展示頁面,要求是響應式和較美觀。由於時間較短,自己動手寫的話也有點麻煩,所以就打算上網找現成的。
中途找到了幾個頁面發現不錯,然後就開始思考怎麼把頁面給下載下來。
由於之前還沒有瞭解過爬蟲,自然也就沒有想到可以用爬蟲來抓取網頁內容。所以我採取的辦法是:
-
開啟chrome的控制檯,進入Application選項
-
找到Frames選項,找到html檔案,再右鍵Save As…
-
手動建立本地的js/css/images目錄
-
依次開啟Frames選項下的Images/Scripts/Stylesheets,一個檔案就要右鍵Save As…
這個辦法是我當時能想到的最好辦法了。不過這種人為的辦法有以下缺點:
-
手工操作,麻煩費時
-
一不小心就忘記儲存哪個檔案
-
難以處理路徑之間的關係,比如一張圖片a.jpg, 它在html中的取用方式是images/banner/a.jpg,這樣我們以後還要手動去解決路徑依賴關係
然後剛好前段時間接觸了一點python,想到可以寫個python爬蟲來幫我自動抓取靜態網站。於是就馬上動手,參考相關資料等等。
下麵跟大家詳細分享一下寫爬蟲抓取靜態網站的全過程。
前置知識儲備
在下麵的程式碼實踐中,用到了python知識、正則運算式等等,核心技術是正則運算式。
我們來一一瞭解一下。
Python基礎知識
如果你之前有過其他語言的學習經歷,相信你可以很快上手python
這門語言。具體學習可以上檢視python官方檔案或者其他教程。
爬蟲的概念
爬蟲,按照我的理解,其實是一段自動執行的計算機程式,在web領域
中,它存在的前提是模擬使用者在瀏覽器中的行為。
它的原理就是模擬使用者訪問web網頁
,獲取網頁內容,然後分析網頁內容,找出我們感興趣的部分,並且最後處理資料。
流程圖是:
現在流行的爬蟲主流實現形式有以下幾種:
-
自己抓取網頁內容,然後自己實現分析過程
-
用別人寫好的爬蟲框架,比如
Scrapy
正則運算式
概念
正則運算式是由一系列元字元和普通字元組成的字串,它的作用是根據一定的規則來匹配文字,最終可以對文字做出一系列的處理。
元字元是正則運算式中的保留字元,它有特殊的匹配規則,比如*
代表匹配0到無窮多次
,普通字元就是普通的abcd
等等。
比如在前端中,常見的一個操作就是判斷使用者的輸入是否為空,這時候我們可以先透過正則運算式來進行匹配,先過濾掉使用者輸入的兩邊空白值,具體實現如下:
function trim(value) {
return value.replace(/^s+|s+$/g, '')
}
// 輸出 => "Python爬蟲"
trim(' Python爬蟲 ');
下麵我們一起來具體瞭解一下正則運算式中的元字元。
正則運算式中的元字元
在上面,我們說過元字元是正則運算式中的保留字元,它有特殊的匹配規則,所以我們首先要瞭解經常出現的元字元。
匹配單個字元的元字元
-
.
代表匹配一個任意字元,除了(換行符),比如可以匹配任意的字母數字等等
-
[...]
表示字元組,裡面可以有任意字元,它只會匹配當中的任意一個,比如[abc]
可以匹配a
或b
或c
,這裡值得註意的是,字元組裡面的元字元有時候會被當成是普通字元,比如[-*?]
等等,它代表的僅僅是-
或*
或?
,而不是-代表區間
,*代表0到無窮次匹配
,?代表0或1次匹配
。 -
[^...]
跟[...]
的含義相反,它的意思是匹配一個不屬於[...]
裡面的字元,而不是不匹配[...]
裡面的字元,這兩種說法雖然細微但是有很大差別,前者規定一定要匹配一個字元,這個切記。
例子:[^123]
可以匹配4/5/6
等等,但是不匹配1/2/3
提供計數功能的元字元
-
*
代表匹配0次到無窮次
,可以不匹配任何字元 -
+
代表匹配1次到無窮次
,至少匹配1次 -
?
代表匹配0次或1次
-
{min, max}
代表匹配min次到max次
,如a{3, 5}
表示a至少匹配3-5次
提供位置的元字元
-
^
代表匹配字串開頭,如^a
表示a要出現在字串開頭,bcd則不匹配 -
$
代表匹配字串結尾, 如A$
表示A要出現在字串結尾,ABAB則不匹配
其他元字元
-
|
代表一個範圍,可以匹配任意的子運算式,比如abc|def
可以匹配abc或者def,不匹配abd -
(...)
代表分組,它的作用有界定子運算式的範圍和與提供功能的元字元相結合,比如(abc|def)+
代表可以匹配1次或1次以上的abc或者defdef,如abcabcabc,def -
i
代表反向取用,i可以為1/2/3等整數,它的含義是指向上一個()裡面匹配的內容。比如匹配(abc)+(12)*
,如果匹配成功的話,的內容是abc,
的內容是12或者空。反向取用通常用在匹配
""
或者''
中
環視
我理解的環視是界定當前匹配子運算式的左邊文字和右邊文字出現的情況,環視本身不會佔據匹配的字元,它是當前子運算式的匹配規則但是本身不算進匹配文字。而我們上面說的元字元都代表一定的規則和佔據一定的字元。
環視可分為四種:肯定順序環視、否定順序環視、肯定逆序環視和否定逆序環視。它們的工作流程如下:
-
肯定順序環視:先找到環視中的文字在右側出現的初始位置,然後從匹配到的右側文字的最左的位置開始匹配字元
-
否定順序環視:先找到環視中的文字在右側沒有出現的初始位置,然後從匹配到的右側文字的最左的位置開始匹配字元
-
肯定逆序環視:先找到環視中的文字在左側出現的初始位置,然後從匹配到的左側文字的最右的位置開始匹配字元
-
否定逆序環視:先找到環視中的文字在左側沒有出現的初始位置,然後從匹配到的左側文字的最右的位置開始匹配字元
肯定順序環視
肯定順序環視匹配成功的條件是當前的子運算式能夠匹配右側文字,它的寫法是(?=...)
,...代表要環視的內容。比如正則運算式(?=hello)he
的意思是匹配包含hello的文字,它只匹配位置,不匹配具體字元,匹配到位置之後,才真正匹配要佔用的字元是he,所以後面可以具體匹配llo等。
對於(?=hello)he
而言,hello world可以匹配成功,而hell world則匹配失敗。具體程式碼如下:
import re
reg1 = r'(?=hello)he'
print(re.search(reg1, 'hello world'))
print(re.search(reg1, 'hell world hello'))
print(re.search(reg1, 'hell world'))
# 輸出結果
<_sre.sre_match object="" span="(0, 2), match='he'>
<_sre.sre_match object="" span="(11, 13), match='he'>
None
否定順序環視
否定順序環視匹配成功的條件是當前的子運算式不能匹配右側文字,它的寫法是(?!...)
,...代表要環視的內容,還是上面的例子,比如正則運算式(?!hello)he
的意思是匹配不是hello的文字,找到位置,然後匹配he。
例子如下:
import re
reg2 = r'(?!hello)he'
print(re.search(reg2, 'hello world'))
print(re.search(reg2, 'hell world hello'))
print(re.search(reg2, 'hell world'))
# 輸出結果
None
<_sre.sre_match object="" span="(0, 2), match='he'>
<_sre.sre_match object="" span="(0, 2), match='he'>
肯定逆序環視
肯定逆序環視匹配成功的條件是當前的子運算式能夠匹配左側文字,它的寫法是(?<=...)
,...代表要環視的內容,比如正則運算式(?<=hello)-python
的意思是匹配包含-python的子運算式,並且它的左側必須出現hello,hello只匹配位置,不匹配具體字元,真正佔用的字元是後面的-python。
例子如下:
import re
reg3 = r'(?<=hello)-python'
print(re.search(reg3, 'hello-python'))
print(re.search(reg3, 'hell-python hello-python'))
print(re.search(reg3, 'hell-python'))
# 輸出結果
<_sre.sre_match object="" span="(5, 12), match='-python'>
<_sre.sre_match object="" span="(17, 24), match='-python'>
None
否定逆序環視
否定逆序環視匹配成功的條件是當前的子運算式不能匹配左側文字,它的寫法是(?,...代表要環視的內容,比如正則運算式
(?的意思是匹配包含-python的子運算式,並且它的左側必須不能出現hello。
例子如下:
import re
reg3 = r'(?<=hello)-python'
print(re.search(reg3, 'hello-python'))
print(re.search(reg3, 'hell-python hello-python'))
print(re.search(reg3, 'hell-python'))
# 輸出結果
<_sre.sre_match object="" span="(5, 12), match='-python'>
<_sre.sre_match object="" span="(17, 24), match='-python'>
None
環視在對字串插入某些字元很有效,你可以利用它來匹配位置,然後插入對應的字元,而不需要對原來的文字進行替換。
捕獲分組
在正則運算式中,分組可以幫助我們提取出想要的特定資訊。
指明分組很簡單,只需要在想捕獲的運算式中兩端加上()
就可以了。在python中,我們可以用re.search(reg, xx).groups()
來獲取到所有的分組。
預設的()
中都指明瞭一個分組,分組序號為i,i從1開始
,分別用re.search(reg, xx).group(i)
來獲取。
如果不想捕獲分組可以使用(?:...)
來指明。
具體例子如下:
import re
reg7 = r'hello,([a-zA-Z0-9]+)'
print(re.search(reg7, 'hello,world').groups())
print(re.search(reg7, 'hello,world').group(1))
print(re.search(reg7, 'hello,python').groups())
print(re.search(reg7, 'hello,python').group(1))
# 輸出結果
('world',)
world
('python',)
python
貪婪匹配
貪婪匹配是指正則運算式盡可能匹配多的字元,也就是趨於最大長度匹配。
正則運算式預設是貪婪樣式。
例子如下:
import re
reg5 = r'hello.*world'
print(re.search(reg5, 'hello world,hello python,hello world,hello javascript'))
# 輸出結果
<_sre.sre_match object="" span="(0, 36), match='hello world,hello python,hello world'>
由上可以看到它匹配的是hello world,hello python,hello world
而不是剛開始的hello world
。那如果我們只是想匹配剛開始的hello world
,這時候我們可以利用正則運算式的非貪婪樣式。
非貪婪匹配正好與貪婪匹配相反,它是指盡可能匹配少的字元,只要匹配到了就結束。要使用貪婪樣式,僅需要在量詞後面加上一個問號(?
)就可以。
還是剛剛那個例子:
import re
reg5 = r'hello.*world'
reg6 = r'hello.*?world'
print(re.search(reg5, 'hello world,hello python,hello world,hello javascript'))
print(re.search(reg6, 'hello world,hello python,hello world,hello javascript'))
# 輸出結果
<_sre.sre_match object="" span="(0, 36), match='hello world,hello python,hello world'>
<_sre.sre_match object="" span="(0, 11), match='hello world'>
由上可以看到這是我們剛剛想要匹配的效果。
進入開發
有了上面的基礎知識,我們就可以進入開發環節了。
我們想實現的最終效果
本次我們的最終目的是寫一個簡單的python爬蟲,這個爬蟲能夠下載一個靜態網頁,並且在保持網頁取用資源的相對路徑下下載它的靜態資源(如js/css/images
)。測試網站為http://www.peersafe.cn/index.html
,效果圖如下:
開發流程
我們的總體思路是先獲取到網頁的內容,然後利用正則運算式來提取我們想要的資源連結,最後就是下載資源。
獲取網頁內容
我們選用python3
自帶的urllib.http
來發出http請求
,或者你可以採用第三方請求庫requests
。
獲取內容的部分程式碼如下:
url = 'http://www.peersafe.cn/index.html'
# 讀取網頁內容
webPage = urllib.request.urlopen(url)
data = webPage.read()
content = data.decode('UTF-8')
print('> 網站內容抓取完畢,內容長度:', len(content))
獲取到內容之後,我們需要把它儲存下來,也就是寫到本地磁碟上。我們定義一個SAVE_PATH
路徑,代表專門放置爬蟲下載的檔案。
# python-spider-downloads是我們要放置的目錄
# 這裡推薦使用os模組來獲取當前的目錄或者拼接路徑
# 不推薦直接使用'F://xxx' + '//python-spider-downloads'等方式
SAVE_PATH = os.path.join(os.path.abspath('.'), 'python-spider-downloads')
接下來就是為這個站點建立一個單獨的檔案夾了。這個站點檔案夾的格式是xxxx-xx-xx-domain
,比如2018-08-03-www.peersafe.cn
。在此之前,我們需要寫一個函式來提取出一個url連結
的域名、相對路徑、請求檔案名和請求引數等等,這個在後續在根據資源檔案的取用方式建立相對應的檔案夾時也會用到。
比如輸入http://www.peersafe.cn/index.html
,那麼將會輸出:
{'baseUrl': 'http://www.peersafe.cn', 'fullPath': 'http://www.peersafe.cn/', 'protocol': 'http://', 'domain': 'www.peersafe.cn', 'path': '/', 'fileName': 'index.html', 'ext': 'html', 'params': ''}
部分程式碼如下:
REG_URL = r'^(https?://|//)?((?:[a-zA-Z0-9-_]+.)+(?:[a-zA-Z0-9-_:]+))((?:/[-_.a-zA-Z0-9]*?)*)((?<=/)[-a-zA-Z0-9]+(?:.([a-zA-Z0-9]+))+)?((?:?[a-zA-Z0-9%&=]*)*)$'
regUrl = re.compile(REG_URL)
# ...
'''
解析URL地址
'''
def parseUrl(url):
if not url:
return
res = regUrl.search(url)
# 在這裡,我們把192.168.1.109:8080的形式也解析成域名domain,實際過程中www.baidu.com等才是域名,192.168.1.109只是IP地址
# ('http://', '192.168.1.109:8080', '/abc/images/111/', 'index.html', 'html', '?a=1&b;=2')
if res is not None:
path = res.group(3)
fullPath = res.group(1) + res.group(2) + res.group(3)
if not path.endswith('/'):
path = path + '/'
fullPath = fullPath + '/'
return dict(
baseUrl=res.group(1) + res.group(2),
fullPath=fullPath,
protocol=res.group(1),
domain=res.group(2),
path=path,
fileName=res.group(4),
ext=res.group(5),
params=res.group(6)
)
'''
解析路徑
eg:
basePath => F:Programspythonpython-spider-downloads
resourcePath => /a/b/c/ or a/b/c
return => F:Programspythonpython-spider-downloadsac
'''
def resolvePath(basePath, resourcePath):
# 解析資源路徑
res = resourcePath.split('/')
# 去掉空目錄 /a/b/c/ => [a, b, c]
dirList = list(filter(lambda x: x, res))
# 目錄不為空
if dirList:
# 拼接出絕對路徑
resourcePath = reduce(lambda x, y: os.path.join(x, y), dirList)
dirStr = os.path.join(basePath, resourcePath)
else:
dirStr = basePath
return dirStr
上面的正則運算式REG_URL
有點長,這個正則運算式能解析目前我遇到的各種url形式
,如果有不能解析的,你可以自行補充,我測試過的url串列可以去我的github中檢視。
首先一個最複雜的url連結(比如'http://192.168.1.109:8080/abc/images/111/index.html?a=1&b;=2'
)來說,我們想分別提取出http://
, 192.168.1.109:8080
, /abc/images/111/
, index.html
, ?a=1&b;=2
。提取出/abc/images/111/
的目的是為以後建立目錄做準備,index.html
是寫入網頁內容的名字。
有需要的可以深入研究一下REG_URL
的寫法,如果有更好的或者看不懂的,我們可以一起探討。
有了parseUrl
函式之後,我們就可以把剛剛獲取網頁內容和寫入檔案聯絡起來了,程式碼如下:
# 首先建立這個站點的檔案夾
urlDict = parseUrl(url)
print('分析的域名:', urlDict)
domain = urlDict['domain']
filePath = time.strftime('%Y-%m-%d', time.localtime()) + '-' + domain
# 如果是192.168.1.1:8000等形式,變成192.168.1.1-8000,:不可以出現在檔案名中
filePath = re.sub(r':', '-', filePath)
SAVE_PATH = os.path.join(SAVE_PATH, filePath)
# 讀取網頁內容
webPage = urllib.request.urlopen(url)
data = webPage.read()
content = data.decode('UTF-8')
print('> 網站內容抓取完畢,內容長度:', len(content))
# 把網站的內容寫下來
pageName = ''
if urlDict['fileName'] is None:
pageName = 'index.html'
else:
pageName = urlDict['fileName']
pageIndexDir = resolvePath(SAVE_PATH, urlDict['path'])
if not os.path.exists(pageIndexDir):
os.makedirs(pageIndexDir)
pageIndexPath = os.path.join(pageIndexDir, pageName)
print('主頁的地址:', pageIndexPath)
f = open(pageIndexPath, 'wb')
f.write(data)
f.close()
提取有用的資源連結
我們想要的資源是圖片資源,js檔案、css檔案和字型檔案
。如果我們要對網頁內容一一進行解析,利用分組,來捕獲出我們想要的連結形式,比如images/1.png
和scripts/lib/jquery.min.js
。
程式碼如下:
REG_RESOURCE_TYPE = r'(?:href|src|data-original|data-src)=["'](.+?.(?:js|css|jpg|jpeg|png|gif|svg|ico|ttf|woff2))[a-zA-Z0-9?=.]*["']'
# re.S代表開啟多行匹配樣式
regResouce = re.compile(REG_RESOURCE_TYPE, re.S)
# ...
# 解析網頁內容,獲取有效的連結
# content是上一步讀取到的網頁內容
contentList = re.split(r's+', content)
resourceList = []
for line in contentList:
resList = regResouce.findall(line)
if resList is not None:
resourceList = resourceList + resList
下載資源
在解析出資源連結後,我們要針對每一個資源連結進行檢查,把它變成符合http請求的url格式,比如把images/1.png
加上http頭
和剛剛的domain
,也就是http://domain/images/1.png
。
下麵是對資源連結進行處理的程式碼:
# ./static/js/index.js
# /static/js/index.js
# static/js/index.js
# //abc.cc/static/js
# http://www.baidu/com/static/index.js
if resourceUrl.startswith('./'):
resourceUrl = urlDict['fullPath'] + resourceUrl[1:]
elif resourceUrl.startswith('//'):
resourceUrl = 'https:' + resourceUrl
elif resourceUrl.startswith('/'):
resourceUrl = urlDict['baseUrl'] + resourceUrl
elif resourceUrl.startswith('http') or resourceUrl.startswith('https'):
# 不處理,這是我們想要的url格式
pass
elif not (resourceUrl.startswith('http') or resourceUrl.startswith('https')):
# static/js/index.js這種情況
resourceUrl = urlDict['fullPath'] + resourceUrl
else:
print('> 未知resource url: %s' % resourceUrl)
接著就是對每個規範的資源連結進行解析(parseUrl
),提取出它要存放的目錄和檔案名等等,然後建立對應的目錄。
在這裡,我也處理了取用的其他網站的資源。
# 解析檔案,檢視檔案路徑
resourceUrlDict = parseUrl(resourceUrl)
if resourceUrlDict is None:
print('> 解析檔案出錯:%s' % resourceUrl)
continue
resourceDomain = resourceUrlDict['domain']
resourcePath = resourceUrlDict['path']
resourceName = resourceUrlDict['fileName']
if resourceDomain != domain:
print('> 該資源不是本網站的,也下載:', resourceDomain)
# 如果下載的話,根目錄就要變了
# 再建立一個目錄,用於儲存其他地方的資源
resourceDomain = re.sub(r':', '-', resourceDomain)
savePath = os.path.join(SAVE_PATH, resourceDomain)
if not os.path.exists(SAVE_PATH):
print('> 標的目錄不存在,建立:', savePath)
os.makedirs(savePath)
# continue
else:
savePath = SAVE_PATH
# 解析資源路徑
dirStr = resolvePath(savePath, resourcePath)
if not os.path.exists(dirStr):
print('> 標的目錄不存在,建立:', dirStr)
os.makedirs(dirStr)
# 寫入檔案
downloadFile(resourceUrl, os.path.join(dirStr, resourceName))
下載的函式downloadFile
的程式碼是:
'''
下載檔案
'''
def downloadFile(srcPath, distPath):
global downloadedList
if distPath in downloadedList:
return
try:
response = urllib.request.urlopen(srcPath)
if response is None or response.status != 200:
return print('> 請求異常:', srcPath)
data = response.read()
f = open(distPath, 'wb')
f.write(data)
f.close()
downloadedList.append(distPath)
# print('>>>: ' + srcPath + ':下載成功')
except Exception as e:
print('報錯了:', e)
以上就是我們的開發全過程。
知識總結
本次開發用到的技術
-
利用
urllib.http
來髮網路請求 -
利用正則運算式來解析資源連結
-
利用
os系統模組
來處理檔案路徑問題
心得體會
這篇文章也算是我這段時間學習python
的一個實踐總結,順便記錄下正則運算式的知識。同時我也希望能夠幫助到那些想學習正則運算式和爬蟲的小夥伴。
該python爬蟲的原始碼已經放在github上(https://github.com/qzcmask/python-codes/blob/master/static-resource-spider.py),有興趣的小夥伴可以上去看看,滿意的可以順便給個
Star
,感謝支援。
●編號489,輸入編號直達本文
●輸入m獲取文章目錄
Web開發
更多推薦《18個技術類微信公眾號》
涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。