Python 爬蟲抓取純靜態網站及其資源
(點擊
上方公眾號
,可快速關注)
來源:
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
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
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
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
否定逆序環視
否定逆序環視匹配成功的條件是當前的子表達式不能匹配左側文本,它的寫法是
(?<!...)
,...代表要環視的內容,比如正則表達式
(?<!hello)-python
的意思是匹配包含-python的子表達式,並且它的左側必須不能出現hello。
例子如下:
import
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
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
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
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
。
獲取內容的部分代碼如下:
"http://www.peersafe.cn/index.html"url =
# 讀取網頁內容
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": ""}
部分代碼如下:
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%&=]*)*)$"REG_URL =
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
resis
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
函數之後,我們就可以把剛剛獲取網頁內容和寫入文件聯繫起來了,代碼如下:
# 首先創建這個站點的文件夾 "分析的域名:" "domain"
urlDict = parseUrl(url)
print(
domain = urlDict[
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
。
代碼如下:
r"(?:href|src|data-original|data-src)=[""](.+?.(?:js|css|jpg|jpeg|png|gif|svg|ico|ttf|woff2))[a-zA-Z0-9?=.]*[""]"REG_RESOURCE_TYPE =
# re.S代表開啟多行匹配模式
regResouce = re.compile(REG_RESOURCE_TYPE, re.S)
# ...
# 解析網頁內容,獲取有效的鏈接
# content是上一步讀取到的網頁內容
contentList = re.split(
r"s+"
, content)resourceList = []
for
linein
contentList:resList = regResouce.findall(line)
if
resListis
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 "./" "fullPath" 1 elif "//" "https:" elif "/" "baseUrl" elif "http" or "https" # 不處理,這是我們想要的url格式 pass elif not "http" or "https" # static/js/index.js這種情況 "fullPath" else "> 未知resource url: %s"
resourceUrl = urlDict[
resourceUrl =
resourceUrl = urlDict[
resourceUrl = urlDict[
print(
接著就是對每個規範的資源鏈接進行解析(
parseUrl
),提取出它要存放的目錄和文件名等等,然後創建對應的目錄。
在這裡,我也處理了引用的其他網站的資源。
# 解析文件,查看文件路徑 if is None "> 解析文件出錯:%s" continue
resourceUrlDict = parseUrl(resourceUrl)
print(
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
distPathin
downloadedList:return
try
:response = urllib.request.urlopen(srcPath)
if
responseis
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
Exceptionas
e:print(
"報錯了:"
, e)以上就是我們的開發全過程。
知識總結
本次開發用到的技術
利用
urllib.http
來發網路請求
利用正則表達式來解析資源鏈接
利用
os系統模塊
來處理文件路徑問題
心得體會
這篇文章也算是我這段時間學習
python
的一個實踐總結,順便記錄下正則表達式的知識。同時我也希望能夠幫助到那些想學習正則表達式和爬蟲的小夥伴。
該python爬蟲的源代碼已經放在github上(https://github.com/qzcmask/python-codes/blob/master/static-resource-spider.py),有興趣的小夥伴可以上去看看。
【關於投稿】
如果大家有原創好文投稿,請直接給公號發送留言。
① 留言格式:
【投稿】+《 文章標題》+ 文章鏈接
② 示例:
【投稿】
《不要自稱是程序員,我十多年的 IT 職場總結》:
http://blog.jobbole.com/94148/
③ 最後請附上您的個人簡介哈~
看完本文有收穫?請轉
發分享給更多人
關注「P
ython開發者」,提升Python技能


※爬蟲進階:反反爬蟲技巧
※Python 之父透露退位隱情,與核心開發團隊產生隔閡
TAG:Python開發者 |