python爬蟲抓取純靜態網站及其資源

遇到的需求

前段時間須要快速作個靜態展現頁面,要求是響應式和較美觀。因爲時間較短,本身動手寫的話也有點麻煩,因此就打算上網找現成的。javascript

中途找到了幾個頁面發現不錯,而後就開始思考怎麼把頁面給下載下來。css

因爲以前尚未了解過爬蟲,天然也就沒有想到能夠用爬蟲來抓取網頁內容。因此我採起的辦法是:html

  1. 打開chrome的控制檯,進入Application選項
  2. 找到Frames選項,找到html文件,再右鍵Save As...
  3. 手動建立本地的js/css/images目錄
  4. 依次打開Frames選項下的Images/Scripts/Stylesheets,一個文件就要右鍵Save As...

這個辦法是我當時能想到的最好辦法了。不過這種人爲的辦法有如下缺點:前端

  1. 手工操做,麻煩費時
  2. 一不當心就忘記保存哪一個文件
  3. 難以處理路徑之間的關係,好比一張圖片a.jpg, 它在html中的引用方式是images/banner/a.jpg,這樣咱們之後還要手動去解決路徑依賴關係

而後恰好前段時間接觸了一點python,想到能夠寫個python爬蟲來幫我自動抓取靜態網站。因而就立刻動手,參考相關資料等等。java

下面跟你們詳細分享一下寫爬蟲抓取靜態網站的全過程。python

前置知識儲備

在下面的代碼實踐中,用到了python知識、正則表達式等等,核心技術是正則表達式jquery

咱們來一一瞭解一下。git

Python基礎知識

若是你以前有過其餘語言的學習經歷,相信你能夠很快上手python這門語言。具體學習能夠上查看python官方文檔或者其餘教程。github

爬蟲的概念

爬蟲,按照個人理解,實際上是一段自動執行的計算機程序,在web領域中,它存在的前提是模擬用戶在瀏覽器中的行爲。web

它的原理就是模擬用戶訪問web網頁,獲取網頁內容,而後分析網頁內容,找出咱們感興趣的部分,而且最後處理數據。

流程圖是:

爬蟲流程圖

如今流行的爬蟲主流實現形式有如下幾種:

  1. 本身抓取網頁內容,而後本身實現分析過程
  2. 用別人寫好的爬蟲框架,好比Scrapy

正則表達式

概念

正則表達式是由一系列元字符和普通字符組成的字符串,它的做用是根據必定的規則來匹配文本,最終能夠對文本作出一系列的處理。

元字符是正則表達式中的保留字符,它有特殊的匹配規則,好比*表明匹配0到無窮屢次,普通字符就是普通的abcd等等。

好比在前端中,常見的一個操做就是判斷用戶的輸入是否爲空,這時候咱們能夠先經過正則表達式來進行匹配,先過濾掉用戶輸入的兩邊空白值,具體實現以下:

function trim(value) {
    return value.replace(/^\s+|\s+$/g, '')
}

// 輸出 => "Python爬蟲"
trim(' Python爬蟲 ');

下面咱們一塊兒來具體瞭解一下正則表達式中的元字符。

正則表達式中的元字符

在上面,咱們說過元字符是正則表達式中的保留字符,它有特殊的匹配規則,因此咱們首先要了解常常出現的元字符。

匹配單個字符的元字符
  • .表明匹配一個任意字符,除了\n(換行符),好比能夠匹配任意的字母數字等等
  • [...]表示字符組,裏面能夠有任意字符,它只會匹配當中的任意一個,好比[abc]能夠匹配abc,這裏值得注意的是,字符組裏面的元字符有時候會被當成是普通字符,好比[-*?]等等,它表明的僅僅是-*?,而不是-表明區間*表明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)*\1\2,若是匹配成功的話,\1的內容是abc,\2的內容是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
否認逆序環視

否認逆序環視匹配成功的條件是當前的子表達式不能匹配左側文本,它的寫法是(?<!...),...表明要環視的內容,好比正則表達式(?<!hello)-python的意思是匹配包含-python的子表達式,而且它的左側必須不能出現hello。

例子以下:

import re

reg4 = r'(?<!hello)-python'
print(re.search(reg4, 'hello-python'))
print(re.search(reg4, 'hell-python hello-python'))
print(re.search(reg4, 'hell-python'))

# 輸出結果
None
<_sre.SRE_Match object; span=(4, 11), match='-python'>
<_sre.SRE_Match object; span=(4, 11), match='-python'>

環視在對字符串插入某些字符頗有效,你能夠利用它來匹配位置,而後插入對應的字符,而不須要對原來的文本進行替換。

捕獲分組

在正則表達式中,分組能夠幫助咱們提取出想要的特定信息。

指明分組很簡單,只須要在想捕獲的表達式中兩端加上()就能夠了。在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:\Programs\python\python-spider-downloads
    resourcePath => /a/b/c/ or a/b/c

    return => F:\Programs\python\python-spider-downloads\a\b\c
'''
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.pngscripts/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)

以上就是咱們的開發全過程。

知識總結

本次開發用到的技術

  1. 利用urllib.http來發網絡請求
  2. 利用正則表達式來解析資源連接
  3. 利用os系統模塊來處理文件路徑問題

心得體會

這篇文章也算是我這段時間學習python的一個實踐總結,順便記錄下正則表達式的知識。同時我也但願可以幫助到那些想學習正則表達式和爬蟲的小夥伴。

該python爬蟲的源代碼已經放在 個人github上,有興趣的小夥伴能夠上去看看,滿意的能夠順便給個 Star,感謝支持。
相關文章
相關標籤/搜索