2021年2月4日 星期四

Python 學習筆記 : 網頁擷取 (二) 使用 requests 套件下載網頁

上一次學習 Python 網路爬蟲已經是三年前的事了 (2018), 那時測試完內建模組 urllib 之後繼續測試較高階的 requests 套件, 但沒完成就去忙別的事了. 最近在讀 "Python 程式超入門 (旗標, 鎌田正浩, 2016)" 這本書, 書末有簡單談到使用 requests 擷取網頁資訊, 所以又重新進行測試, 結果紀錄如下. 

上一篇 Python 網頁擷取測試中使用了內建的 urllib 與 HTMLParser 模組來擷取並剖析網頁內容, 本篇則要進一步使用更方便的第三方模組 requests 來取得網頁內容.

以下的測試參考了下列 Python 網頁擷取相關書籍 :
  1.  Python 網路爬蟲實戰 (松崗, 胡松濤)
  2.  Python 自動化的樂趣 (碁峰, AL Sweigart)
  3.  Python 程式設計實務 (博碩, 何敏煌)
  4.  Python 初學特訓班 (碁峰, 文淵閣工作室)
  5.  網站擷取 : 使用 Python (碁峰, Ryan Mitchell)
  6.  Python 入門邁向高手之路-王者歸來 (深石, 洪錦魁)
  7.  Python 程式設計入門指南 (碁峰, 蔡明志譯)
  8.  Web Scraping with Python (Packt, Richard Lawson)
  9.  Python Web Scraping Cookbook (Packt, Michael Heydt)
  10.  Automate the Boring Stuff with Python (Starch, AL Sweigart)
  11.  Introduction to Programming Using Python (Pearson, Y. Daniel Liang)
  12.  Python Web Scraping : Fetching data from the web (Packt, Katharine Jarmul)
  13.  Data Wrangling with Python (O'Reilly, Jacqueline Kazil & Katharine Jarmul)
  14.  Web Scraping with Python : Collecting Data from the Modern Web (O'Reilly, Ryan Mitchell) 

第三方套件 requests 是比 Python 3 內建的 urllib 更方便好用的爬蟲函式庫, 參考說明文件 :

http://docs.python-requests.org/en/master/   (英文)
http://cn.python-requests.org/zh_CN/latest/ (中文)
https://2.python-requests.org//zh_CN/latest/user/quickstart.html (中文)
http://docs.python-requests.org/en/master/api/?highlight=codes (API)


一. 安裝 requests 模組 :

使用 requests 前必須先安裝, 指令如下 :

pip3 install  -U requests 

安裝好 requests 後即可用 import 匯入 :

import requests


二. requests 實作的 6 個 HTTP 方法 :

requests 模組實作了 HTTP/1.1 定義之八種方法中的六種 : GET, POST, PUT, DELETE, HEAD, OPTIONS (TRACE 與 CONNECT 這兩個方法未實作), 其相對應的物件方法如下 :

 HTTP 方法 物件方法 說明
 GET requests.get(url [, params]) 向指定資源提交請求, params 為參數字典
 POST requests.post(url [, data]) 向指定資源提交請求, data 為參數字典
 PUT requests.put(url [, data]) 向指定資源提供最新內容, data 為參數字典
 DELETE requests.delete(url) 請求刪除指定之資源
 HEAD requests.head(url) 請求提供資源之回應標頭 (不含內容)
 OPTIONS requests.options(url) 請求伺服器提供資源可用之功能選項

呼叫 requests 的這六個方法會傳回一個 Response 物件. 其中 GET 與 POST 是最常用的方法, 主要的差別是 GET 如果有提交參數, 其參數是放在標頭中傳送 (公開), 而 POST 則是放在內容中傳送 (隱密). POST 請求可能會導致新資源之建立或既有資源之刪除, GET 則不適合進行這樣的操作.

OPTIONS 通常是用在要對資源採取具體請求之前先向伺服器查詢該資源可用之功能選項, 以便決定要對此資源進行何種請求. 若 OPTIONS 方法請求之 URL 為星號 *, 表示想要查詢整個伺服器性能 (此為駭客常用方法).

PUT 是向指定之資源更新最新內容 (例如 UPDATE 或 ADD 動作), 其相對應之方法為 DELETE, 此為請求伺服器刪除指定之資源. 需注意的是, DELETE 方法即使回應 200 OK 並非表示伺服器已經依照要求刪除該資源, 而僅僅表示收到該請求而已.

參考 :

HTTP的請求方法OPTIONS
HTTP/1.1協議中共定義了八種方法

以下參考 requests 官網教學內容進行測試, 它使用 http://httpbin.org 網站進行測試, 此網站提供了除 CONNECT 與 TRACE 外的六種 HTTP 方法之資源 :

 HTTP 方法 資源網址
 GET http://httpbin.org/get
 POST http://httpbin.org/post
 PUT http://httpbin.org/put
 DELETE http://httpbin.org/delete
 HEAD http://httpbin.org/head
 OPTIONS http://httpbin.org/options

參考 :

http://docs.python-requests.org/en/master/user/quickstart/#make-a-request


三. Response 物件的屬性與方法 :

呼叫 requests 模組所定義的六種 HTTP 方法會傳回一個 Response 物件, 它包裹了伺服器回應訊息中的資訊, 可透過如下屬性與方法查詢回應內容 :

 Response 物件 說明
 url 要求之資源 URL 位址
 content 回應訊息之內容 (bytes)
 text 回應訊息之內容字串 (str)
 raw 原始回應訊息串流 (bytes)
 status_code 回應狀態 (int)
 encoding 回應訊息編碼, 例如 utf-8
 headers 回應訊息之標頭 (dict)
 cookies 回應訊息中的 cookies (dict)
 history 請求歷史 (list)
 json() 將回應訊息進行 JSON 解碼後傳回 (dict)
 rasise_for_status() 檢查是否有例外發生, 有則拋出例外

例如對 http://httpbin.org/get 資源提出 GET 要求的話, 測試結果如下 :

>>> import requests
>>> r=requests.get('http://httpbin.org/get')
>>> r.raise_for_status()    #200 OK 傳回 None, 否則拋出例外
>>> type(r)    
<class 'requests.models.Response'>    #傳回值為 Response 物件
>>> r
<Response [200]>          #只顯示物件名稱與回應狀態 200 OK
>>> r.status_code 
200
>>> type(r.status_code)      #status_code 為 int 整數
<class 'int'>
>>> requests.codes
<lookup 'status_codes'>
>>> type(requests.codes)
<class 'requests.structures.LookupDict'> 

其中 status_code 屬性為一個整數, 表示 HTTP 回應的狀態, 正常為 200 (OK),  否則表示下載網頁時出現錯誤或異常, raise_for_status() 方法就是用來檢查回應狀態, 通常在下載網頁後緊接著就呼叫此方法, 如果是 200 OK 傳回 None; 否則就會拋出例外並終止程式. 常見的錯誤狀態碼例如 400 (Bad request), 404 (Not found), 500 (Server internal error) 等, 參考 :

# Wiki : HTTP 狀態碼

requests 模組將 HTTP 回應狀態碼定義在 requests.codes 物件內, 此物件為 LookupDict 類別之物件, 以字典形式儲存狀態碼之布林值. 若 requests.codes.ok 之值為 true 表示回應狀態為 200 OK, 可用來判別要求之回應是否正常, 例如 :

if (r.status_code == requests.codes.ok):
    print("Response : OK")

當然如果記得狀態碼也可以直接用狀態碼 :

if (r.status_code == 200):
    print("Response : OK")

由於 requests.codes 為 LookupDict 類別之物件, 以字典形式儲存狀態碼, 因此 requests.codes.ok 也可以用 requests.codes["ok"] 來存取, 其他狀態碼也是如此, 參考 :

Does requests.codes.ok include a 304?

例如 :

>>> requests.codes.ok 
200
>>> requests.codes['ok'] 
200
>>> requests.codes.not_modified   
304
>>> requests.codes['not_modified'] 
304
>>> requests.codes.bad_request   
400
>>> requests.codes['bad_request'] 
400
>>> requests.codes.not_found   
404
>>> requests.codes['not_found']   
404
>>> requests.codes.internal_server_error     
500
>>> requests.codes['internal_server_error']   
500

狀態屬性之命名原則為全小寫, 而且以底線相連. 例如最常見的錯誤為 404 網頁不存在 (Not found), 其 key 即為 not_found. 關於回應狀態. 由於網路屬於一種 IO, 因此下載網頁時可能會發生錯誤, 例如下載不存在的 網頁時會出現 Not found 錯誤 :

>>> r=requests.get('http://tw.yahoo.com/xyz.htm')   #不存在
>>> r.raise_for_status()     #檢查回應狀態
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
  File "C:\Users\Tony Huang\.thonny\BundledPython36\lib\site-packages\requests\models.py", line 939, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 404 Client Error: Not Found for url:   https://tw.yahoo.com/xyz.htm

這個密密麻麻的例外訊息會讓使用者納悶, 理想的做法是將 raise_for_status() 等指令放在 try except 結構中攔截可能的例外, 讓程式在被規範的情形下終止, 同時提供使用者較友善之錯誤訊息, 例如 :

import requests   

try:
    print("Download web page ...")
    r=requests.get('http://tw.yahoo.com/xyz.htm') 
    r.raise_for_status()    
except Exception as e:   
    print(e) 

Response 物件最常用的是用來取得回應訊息內容的 text 屬性與 json() 方法. text 屬性以字串形式儲存回應訊息之內容 (即 HTTP 之 BODY 部分); 而 json() 則傳回 dict 型態的 JSON 編碼 回應內容. 例如 :

>>> import requests 
>>> r=requests.get('http://httpbin.org/get') 
>>> r.text              #顯示回應訊息內容 (字串)
'{"args":{},"headers":{"Accept":"*/*","Accept-Encoding":"gzip, deflate","Connection":"close","Host":"httpbin.org","User-Agent":"python-requests/2.18.1"},"origin":"114.27.103.62","url":"http://httpbin.org/get"}\n'
>>> r.json()           #以 JSON 型態顯示回應訊息內容 (字典)
>>> type(r.text)    #屬性 text 型態為字串
<class 'str'> 
{'args': {}, 'headers': {'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate', 'Connection': 'close', 'Host': 'httpbin.org', 'User-Agent': 'python-requests/2.18.1'}, 'origin': '114.27.103.62', 'url': 'http://httpbin.org/get'}
>>> type(r.json()) 
<class 'dict'> 

可見 json() 的傳回值與 text 屬性值內容一樣, 但型態不同, 前者是 dict 後者是 str. 注意, 回應訊息的內容必須是 JSON 格式, 否則呼叫 json() 時將拋出 ValueError 例外, 例如下面這個 PHP 爬蟲測試網頁的回應是單純的 HTML 碼, 並非 JSON 格式之資料, 呼叫 json() 後就會因為解碼失敗而拋出例外 :

>>> import requests 
>>> url="http://www.webbotsspidersscreenscrapers.com/hello_world.html"
>>> r=requests.get(url) 
>>> r.text                      #回應內容為 HTML 網頁, 非 JSON 格式
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">\r\n\r\n<html>\r\n<head>\r\n\t<title>Hello, world!</title>\r\n</head>\r\n\r\n<body>\r\nCongratulations! If you can read this, <br>\r\nyou successfully downloaded this file.\r\n</body>\r\n</html>\r\n'
>>> r.json()                   #回應內容不是 JSON 格式拋出例外
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
  File "C:\Users\Tony Huang\.thonny\BundledPython36\lib\site-packages\requests\models.py", line 896, in json
    return complexjson.loads(self.text, **kwargs)
  File "C:\Users\Tony Huang\AppData\Local\Programs\Thonny\lib\json\__init__.py", line 354, in loads
    return _default_decoder.decode(s)
  File "C:\Users\Tony Huang\AppData\Local\Programs\Thonny\lib\json\decoder.py", line 339, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "C:\Users\Tony Huang\AppData\Local\Programs\Thonny\lib\json\decoder.py", line 357, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

與 raise_for_status() 一樣, 可能會拋出例外的 json() 應該放在 try ... except 中較好, 例如 :

import requests
url="http://www.webbotsspidersscreenscrapers.com/hello_world.html"
r=requests.get(url)
try: 
    r.json()     #回應訊息不是 JSON 格式, 會拋出例外
except Exception as e: 
    print(e) 

除了 text 屬性外, content 屬性也可以取得回應訊息之內容, 差別是 content 的類型是 bytes, 而 text 的類型是 str, 例如 :

>>> import requests
>>> url="http://www.webbotsspidersscreenscrapers.com/hello_world.html"
>>> r=requests.get(url)
>>> r.text 
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">\r\n\r\n<html>\r\n<head>\r\n\t<title>Hello, world!</title>\r\n</head>\r\n\r\n<body>\r\nCongratulations! If you can read this, <br>\r\nyou successfully downloaded this file.\r\n</body>\r\n</html>\r\n'
>>> type(r.text) 
<class 'str'>
>>> r.content 
b'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">\r\n\r\n<html>\r\n<head>\r\n\t<title>Hello, world!</title>\r\n</head>\r\n\r\n<body>\r\nCongratulations! If you can read this, <br>\r\nyou successfully downloaded this file.\r\n</body>\r\n</html>\r\n'
>>> type(r.content) 
<class 'bytes'> 

另外 raw 屬性也是儲存回應訊息的內容, 不過它儲存的是原始的 HTTP 回應訊息串流 (Stream), 必須呼叫 read() 方法才能將回應訊息讀出來. 注意, 在呼叫 HTTP 方法如 get() 或 post() 時須傳入 stream=True 參數 (預設為 False) 才能取得 raw 屬性, 否則讀取不到資料, 例如 :

>>> import requests
>>> url="http://www.webbotsspidersscreenscrapers.com/hello_world.html"
>>> r=requests.get(url, stream=True) 
>>> r.raw
<urllib3.response.HTTPResponse object at 0x0393D690>
>>> type(r.raw)
<class 'urllib3.response.HTTPResponse'>
>>> r.raw.read(10)     #從原始回應讀取 10 個 bytes, 並從緩衝區刪除
b'\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03'
>>> r.raw.read(20)     #從原始回應讀取 20 個 bytes, 並從緩衝區刪除
b'-\x8e\xc1\n\x830\x10D\xcf\x15\xfc\x87\xd5sk\n\xed1x\xa8'
>>> r.raw.read()           ##從原始回應讀取剩餘的全部 bytes, 並從緩衝區刪除
b'\n\n\xb6\xf5`)=F\x13k`\x9b@\x12\x11\xff\xbe\x11=\xcd\xb0\xf3\x98Y\x1a\xe5\xcf\xac\xfd4\x05\x94\xed\xbd\x86\xe6u\xab\xab\x0c\xe2\x13!\xefKFH\xde\xe6[pM\xce\xd0\x1a\xa6\xactR+\x86\x84\x14\x8f8\r\x830\xa0\xa3\xfba\xba\xaa`\xdc\xeb\x81:\xe9P\xa4\xa5@\xd4G\x98\xb5A\x1eQ\xb2\x1d=Fv\xce\xdbN\xf3\xc5\xbbL\xab\xafanB\xb6v\xdb\x08\xaa\x01\x16=A\xcf\x14\x18\x0f\x83\x1b\xa5=\x02\xed\x8c\x87\xd7\xc0N}/\xac\x1d&\xc4\x05\xb8\x9e\x15j\xc6\xc5\xc6\xc1 Q$\xeb\xce\xde\xee\x07\xb7\x07\xffgb\xaeY\xeb\x00\x00\x00'

與 text, content 屬性不同的是, raw 是串流資料, 讀取後就會自緩衝器中刪除, 不像 text 與 content 那樣會一直存在.

參考 :

How to get the raw content of a response in requests with Python?

屬性 url 儲存所要求之資源網址; 而屬性 encoding 則是儲存回應網頁之編碼, 例如 Yahoo 股市股利政策網頁編碼為 Big5 :

>>> url="http://tw.stock.yahoo.com/d/s/dividend_2330.html" 
>>> r=requests.get(url)   
>>> r.url     #要求之網頁網址
'https://tw.stock.yahoo.com/d/s/dividend_2330.html' 
>>> r.encoding     #編碼格式
'Big5'   
>>> r.cookies
<RequestsCookieJar[]>

但並非每一個網頁都會在 head 標籤內註明編碼格式, 其 encoding 即為 None, 例如 :

>>> r=requests.get('http://httpbin.org/get') 
>>> r.encoding      #此網頁無 encoding
>>> r.url   
'http://httpbin.org/get'

headers 屬性以字典 (dict) 的形式儲存回應訊息之標頭 (header), 每一個參數可用 key 或呼叫 get() 方法存取, 例如下列網頁的標頭顯示內容型態 (Content-Type) 為 JSON 格式 :

>>> r=requests.get('http://httpbin.org/get') 
>>> r.headers   
{'Connection': 'keep-alive', 'Server': 'gunicorn/19.8.1', 'Date': 'Fri, 29 Jun 2018 08:51:21 GMT', 'Content-Type': 'application/json', 'Content-Length': '208', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true', 'Via': '1.1 vegur'}
>>> r.headers['Content-Type']
'application/json'
>>> r.headers.get('Content-Type') 
'application/json'

而下列網頁標頭顯示內容型態 (Content-Type) 為 HTML 格式 :

>>>
url="http://www.webbotsspidersscreenscrapers.com/hello_world.html" 
>>> r=requests.get(url) 
>>> r.headers
{'Date': 'Fri, 29 Jun 2018 08:52:00 GMT', 'Server': 'Apache', 'Accept-Ranges': 'bytes', 'Vary': 'Accept-Encoding', 'Content-Encoding': 'gzip', 'Content-Length': '196', 'Keep-Alive': 'timeout=5, max=100', 'Connection': 'Keep-Alive', 'Content-Type': 'text/html'}


四. 將下載的網頁存成檔案 :

用 requests 的 get() 或 post() 所下載的網頁是以 Response 物件的型態儲存, 如果要儲存到本機檔案系統中的話可以使用 Response 物件的 iter_content(chunksize) 方法將回應內容以 byte 為單位讀取後呼叫 write() 寫入本地檔案中 (要用 'wb' 模式), 參考 :

http://docs.python-requests.org/en/master/api/#requests.Response.iter_content

iter_content(chunksize) 的參數 chunksize 指定每次從回應訊息內容讀取之 bytes 數, 預設為 1 byte, 數字越大占用記憶體越多. 在 "Python 入門邁向高手之路-王者歸來" 這本書的 21-2-7 節有存成檔案的範例, 改寫如下 :

import requests
url="http://www.webbotsspidersscreenscrapers.com/hello_world.html"
file="hello_world.txt"     #欲儲存之檔案名稱
try:
    r=requests.get(url)
    r.raise_for_status()
    print("網頁下載 OK")
    with open(file, "wb") as fobj:           #以 wb 模式開啟檔案為 fobj 物件
        for data in r.iter_content(50):      #每次讀取 50 bytes
            size=fobj.write(data)                 #寫入檔案
            print(size)
        print("儲存網頁檔 %s OK" %file)
except Exception as e:
    print(e)

在 Thonny 執行結果如下 :

Python 3.6.4
>>> %Run status.py     
網頁下載 OK
50
50
50
50
35
儲存網頁檔 hello_world.txt OK

此程式會將下載的網頁儲存為 hello_world.txt 於程式目前目錄下, 每次讀取 50 bytes, 原網頁 235 bytes 便分成 5 次讀取. 開啟 hello_world.txt 內容如下 :

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">

<html>
<head>
<title>Hello, world!</title>
</head>

<body>
Congratulations! If you can read this, <br>
you successfully downloaded this file.
</body>
</html>

儲存網頁時檔名可以包含路徑, 但要注意, 如果在 Windows 中要寫入指定之目錄例如 D:\Python\test 下的話, 路徑分隔符號需跳脫, 例如 :

file="D:\\Python\\test\\hello_world.txt"

沒有留言 :