如何使用 urllib 包獲取 Internet 資源

作者:

Michael Foord

簡介

urllib.request 是一個用於獲取 URL(統一資源定位符)的 Python 模組。它提供了一個非常簡單的介面,形式為 urlopen 函式。該函式能夠使用各種不同的協議獲取 URL。它還提供了一個稍微複雜一些的介面,用於處理常見情況——如基本身份驗證、cookie、代理等。這些由稱為處理程式(handler)和 opener 的物件提供。

urllib.request 支援使用其相關的網路協議(例如 FTP、HTTP)獲取許多“URL 方案”(由 URL 中 ":" 之前的字串標識 - 例如 "ftp""ftp://python.club.tw/" 的 URL 方案)。本教程側重於最常見的 HTTP 情況。

對於簡單的情況,urlopen 非常容易使用。但是,一旦在開啟 HTTP URL 時遇到錯誤或非平凡的情況,您將需要一些對超文字傳輸協議的理解。關於 HTTP 最全面和權威的參考資料是 RFC 2616。這是一份技術文件,並非旨在易於閱讀。本 HOWTO 旨在說明如何使用 urllib,並提供足夠的關於 HTTP 的詳細資訊來幫助您。它並非旨在取代 urllib.request 文件,而是對它們的補充。

獲取 URL

使用 urllib.request 的最簡單方法如下

import urllib.request
with urllib.request.urlopen('https://python.club.tw/') as response:
   html = response.read()

如果您希望透過 URL 檢索資源並將其儲存在臨時位置,則可以透過 shutil.copyfileobj()tempfile.NamedTemporaryFile() 函式來實現

import shutil
import tempfile
import urllib.request

with urllib.request.urlopen('https://python.club.tw/') as response:
    with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
        shutil.copyfileobj(response, tmp_file)

with open(tmp_file.name) as html:
    pass

許多 urllib 的用法都如此簡單(請注意,我們可以使用以 ‘ftp:’、‘file:’ 等開頭的 URL 來代替 ‘http:’ URL)。但是,本教程的目的是解釋更復雜的情況,重點是 HTTP。

HTTP 基於請求和響應 - 客戶端發出請求,伺服器傳送響應。urllib.request 使用 Request 物件來映象此過程,該物件表示您正在發出的 HTTP 請求。在其最簡單的形式中,您可以建立一個 Request 物件,該物件指定要獲取的 URL。使用此 Request 物件呼叫 urlopen 會返回所請求 URL 的響應物件。此響應是一個類似檔案的物件,這意味著您可以例如對響應呼叫 .read()

import urllib.request

req = urllib.request.Request('https://python.club.tw/')
with urllib.request.urlopen(req) as response:
   the_page = response.read()

請注意,urllib.request 使用相同的 Request 介面來處理所有 URL 方案。例如,您可以像這樣發出 FTP 請求

req = urllib.request.Request('ftp://example.com/')

在 HTTP 的情況下,Request 物件允許您執行另外兩件事:首先,您可以傳遞要傳送到伺服器的資料。其次,您可以將有關資料或請求本身的額外資訊(“元資料”)傳遞給伺服器——此資訊作為 HTTP “標頭”傳送。讓我們依次檢視這些內容。

資料

有時您想將資料傳送到 URL(通常 URL 將引用 CGI(公共閘道器介面)指令碼或其他 Web 應用程式)。在 HTTP 中,這通常使用所謂的 POST 請求來完成。這通常是您在 Web 上提交填寫的 HTML 表單時瀏覽器所做的操作。並非所有 POST 都必須來自表單:您可以使用 POST 將任意資料傳輸到您自己的應用程式。在 HTML 表單的常見情況下,資料需要以標準方式進行編碼,然後作為 data 引數傳遞給 Request 物件。編碼是使用 urllib.parse 庫中的函式完成的。

import urllib.parse
import urllib.request

url = 'http://www.someserver.com/cgi-bin/register.cgi'
values = {'name' : 'Michael Foord',
          'location' : 'Northampton',
          'language' : 'Python' }

data = urllib.parse.urlencode(values)
data = data.encode('ascii') # data should be bytes
req = urllib.request.Request(url, data)
with urllib.request.urlopen(req) as response:
   the_page = response.read()

請注意,有時需要其他編碼(例如,用於從 HTML 表單上傳檔案 - 有關更多詳細資訊,請參閱 HTML 規範,表單提交)。

如果您不傳遞 data 引數,則 urllib 使用 GET 請求。GET 和 POST 請求之間的一個區別是,POST 請求通常具有“副作用”:它們以某種方式更改系統的狀態(例如,透過向網站下訂單,將一百磅罐裝午餐肉送到您家門口)。儘管 HTTP 標準明確指出 POST 旨在始終導致副作用,而 GET 請求永不導致副作用,但沒有任何東西可以阻止 GET 請求產生副作用,也沒有任何東西可以阻止 POST 請求沒有副作用。資料也可以透過將其編碼在 URL 本身中來在 HTTP GET 請求中傳遞。

這按如下方式完成

>>> import urllib.request
>>> import urllib.parse
>>> data = {}
>>> data['name'] = 'Somebody Here'
>>> data['location'] = 'Northampton'
>>> data['language'] = 'Python'
>>> url_values = urllib.parse.urlencode(data)
>>> print(url_values)  # The order may differ from below.  
name=Somebody+Here&language=Python&location=Northampton
>>> url = 'http://www.example.com/example.cgi'
>>> full_url = url + '?' + url_values
>>> data = urllib.request.urlopen(full_url)

請注意,完整的 URL 是透過在 URL 中新增 ?,後跟編碼的值來建立的。

標頭

我們將在此處討論一個特定的 HTTP 標頭,以說明如何將標頭新增到 HTTP 請求中。

一些網站 [1] 不喜歡被程式瀏覽,或者向不同的瀏覽器傳送不同的版本 [2]。預設情況下,urllib 將自己標識為 Python-urllib/x.y (其中 xy 是 Python 版本的 主版本號和次版本號,例如 Python-urllib/2.5),這可能會使站點混淆,或者根本不起作用。瀏覽器標識自己的方式是透過 User-Agent 標頭 [3]。當您建立 Request 物件時,您可以傳入一個標頭字典。以下示例執行與上述相同的請求,但將自己標識為 Internet Explorer 的版本 [4]

import urllib.parse
import urllib.request

url = 'http://www.someserver.com/cgi-bin/register.cgi'
user_agent = 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)'
values = {'name': 'Michael Foord',
          'location': 'Northampton',
          'language': 'Python' }
headers = {'User-Agent': user_agent}

data = urllib.parse.urlencode(values)
data = data.encode('ascii')
req = urllib.request.Request(url, data, headers)
with urllib.request.urlopen(req) as response:
   the_page = response.read()

響應還有兩個有用的方法。請參閱info 和 geturl 部分,該部分在我們查看出現問題時會發生什麼之後。

處理異常

urlopen 無法處理響應時,會引發 URLError (但與 Python API 的常見情況一樣,也可能引發諸如 ValueErrorTypeError 等內建異常)。

HTTPError 是在 HTTP URL 的特定情況下引發的 URLError 的子類。

異常類是從 urllib.error 模組匯出的。

URLError

通常,引發 URLError 是因為沒有網路連線(沒有到指定伺服器的路由),或者指定的伺服器不存在。在這種情況下,引發的異常將具有一個 ‘reason’ 屬性,該屬性是一個包含錯誤程式碼和文字錯誤訊息的元組。

例如

>>> req = urllib.request.Request('http://www.pretend_server.org')
>>> try: urllib.request.urlopen(req)
... except urllib.error.URLError as e:
...     print(e.reason)      
...
(4, 'getaddrinfo failed')

HTTPError

伺服器返回的每個 HTTP 響應都包含一個數字“狀態碼”。有時,狀態碼錶示伺服器無法滿足請求。預設的處理程式會為您處理某些此類響應(例如,如果響應是請求客戶端從不同的 URL 獲取文件的“重定向”,則 urllib 會為您處理)。對於它無法處理的響應,urlopen 將引發一個 HTTPError。常見的錯誤包括 ‘404’(頁面未找到)、‘403’(請求被禁止)和 ‘401’(需要身份驗證)。

有關所有 HTTP 錯誤程式碼的參考,請參閱 RFC 2616 的第 10 節。

引發的 HTTPError 例項將具有一個整數 ‘code’ 屬性,該屬性對應於伺服器傳送的錯誤。

錯誤程式碼

由於預設處理程式處理重定向(300 範圍內的程式碼),並且 100-299 範圍內的程式碼表示成功,因此您通常只會看到 400-599 範圍內的錯誤程式碼。

http.server.BaseHTTPRequestHandler.responses 是一個有用的響應程式碼字典,其中顯示了 RFC 2616 使用的所有響應程式碼。為了方便起見,這裡重現了該字典

# Table mapping response codes to messages; entries have the
# form {code: (shortmessage, longmessage)}.
responses = {
    100: ('Continue', 'Request received, please continue'),
    101: ('Switching Protocols',
          'Switching to new protocol; obey Upgrade header'),

    200: ('OK', 'Request fulfilled, document follows'),
    201: ('Created', 'Document created, URL follows'),
    202: ('Accepted',
          'Request accepted, processing continues off-line'),
    203: ('Non-Authoritative Information', 'Request fulfilled from cache'),
    204: ('No Content', 'Request fulfilled, nothing follows'),
    205: ('Reset Content', 'Clear input form for further input.'),
    206: ('Partial Content', 'Partial content follows.'),

    300: ('Multiple Choices',
          'Object has several resources -- see URI list'),
    301: ('Moved Permanently', 'Object moved permanently -- see URI list'),
    302: ('Found', 'Object moved temporarily -- see URI list'),
    303: ('See Other', 'Object moved -- see Method and URL list'),
    304: ('Not Modified',
          'Document has not changed since given time'),
    305: ('Use Proxy',
          'You must use proxy specified in Location to access this '
          'resource.'),
    307: ('Temporary Redirect',
          'Object moved temporarily -- see URI list'),

    400: ('Bad Request',
          'Bad request syntax or unsupported method'),
    401: ('Unauthorized',
          'No permission -- see authorization schemes'),
    402: ('Payment Required',
          'No payment -- see charging schemes'),
    403: ('Forbidden',
          'Request forbidden -- authorization will not help'),
    404: ('Not Found', 'Nothing matches the given URI'),
    405: ('Method Not Allowed',
          'Specified method is invalid for this server.'),
    406: ('Not Acceptable', 'URI not available in preferred format.'),
    407: ('Proxy Authentication Required', 'You must authenticate with '
          'this proxy before proceeding.'),
    408: ('Request Timeout', 'Request timed out; try again later.'),
    409: ('Conflict', 'Request conflict.'),
    410: ('Gone',
          'URI no longer exists and has been permanently removed.'),
    411: ('Length Required', 'Client must specify Content-Length.'),
    412: ('Precondition Failed', 'Precondition in headers is false.'),
    413: ('Request Entity Too Large', 'Entity is too large.'),
    414: ('Request-URI Too Long', 'URI is too long.'),
    415: ('Unsupported Media Type', 'Entity body in unsupported format.'),
    416: ('Requested Range Not Satisfiable',
          'Cannot satisfy request range.'),
    417: ('Expectation Failed',
          'Expect condition could not be satisfied.'),

    500: ('Internal Server Error', 'Server got itself in trouble'),
    501: ('Not Implemented',
          'Server does not support this operation'),
    502: ('Bad Gateway', 'Invalid responses from another server/proxy.'),
    503: ('Service Unavailable',
          'The server cannot process the request due to a high load'),
    504: ('Gateway Timeout',
          'The gateway server did not receive a timely response'),
    505: ('HTTP Version Not Supported', 'Cannot fulfill request.'),
    }

當引發錯誤時,伺服器透過返回 HTTP 錯誤程式碼錯誤頁面來響應。您可以使用 HTTPError 例項作為返回頁面上的響應。這意味著除了 code 屬性之外,它還具有 urllib.response 模組返回的 read、geturl 和 info 方法。

>>> req = urllib.request.Request('https://python.club.tw/fish.html')
>>> try:
...     urllib.request.urlopen(req)
... except urllib.error.HTTPError as e:
...     print(e.code)
...     print(e.read())  
...
404
b'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n\n\n<html
  ...
  <title>Page Not Found</title>\n
  ...

總結

因此,如果您想為 HTTPError URLError 做好準備,有兩種基本方法。我更喜歡第二種方法。

方法 1

from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
req = Request(someurl)
try:
    response = urlopen(req)
except HTTPError as e:
    print('The server couldn\'t fulfill the request.')
    print('Error code: ', e.code)
except URLError as e:
    print('We failed to reach a server.')
    print('Reason: ', e.reason)
else:
    # everything is fine

注意

except HTTPError 必須放在首位,否則 except URLError 會捕獲 HTTPError

方法 2

from urllib.request import Request, urlopen
from urllib.error import URLError
req = Request(someurl)
try:
    response = urlopen(req)
except URLError as e:
    if hasattr(e, 'reason'):
        print('We failed to reach a server.')
        print('Reason: ', e.reason)
    elif hasattr(e, 'code'):
        print('The server couldn\'t fulfill the request.')
        print('Error code: ', e.code)
else:
    # everything is fine

info 和 geturl

urlopen 返回的響應(或 HTTPError 例項)有兩個有用的方法 info()geturl(),並在 urllib.response 模組中定義。

  • geturl - 這會返回獲取的頁面的真實 URL。這很有用,因為 urlopen(或使用的 opener 物件)可能已跟隨重定向。獲取的頁面的 URL 可能與請求的 URL 不同。

  • info - 這會返回一個類似字典的物件,用於描述獲取的頁面,特別是伺服器傳送的標頭。它當前是一個 http.client.HTTPMessage 例項。

常見的標頭包括 ‘Content-length’、‘Content-type’ 等。有關 HTTP 標頭的有用列表及其含義和用法的簡要說明,請參閱 HTTP 標頭快速參考

Openers 和 Handlers

當您獲取 URL 時,您會使用 opener(一個名為 urllib.request.OpenerDirector 的例項,這可能令人困惑)。通常,我們一直在使用預設的 opener - 透過 urlopen - 但您可以建立自定義的 openers。Openers 使用 handlers。所有“繁重的工作”都由 handlers 完成。每個 handler 都知道如何為特定的 URL 方案(http、ftp 等)開啟 URL,或者如何處理 URL 開啟的某個方面,例如 HTTP 重定向或 HTTP cookie。

如果您想使用安裝了特定 handlers 的 openers 獲取 URL,例如要獲取一個處理 cookie 的 opener,或者要獲取一個不處理重定向的 opener,則需要建立 openers。

要建立 opener,請例項化一個 OpenerDirector,然後重複呼叫 .add_handler(some_handler_instance)

或者,您可以使用 build_opener,這是一個方便的函式,用於透過單個函式呼叫建立 opener 物件。build_opener 預設新增多個 handlers,但提供了一種快速新增更多 handlers 和/或覆蓋預設 handlers 的方法。

您可能需要的其他型別的 handlers 可以處理代理、身份驗證和其他常見但略微特殊的情況。

install_opener 可用於使 opener 物件成為(全域性)預設 opener。這意味著對 urlopen 的呼叫將使用您安裝的 opener。

Opener 物件具有一個 open 方法,可以直接呼叫該方法以與 urlopen 函式相同的方式獲取 URL:除非為了方便起見,否則無需呼叫 install_opener

基本身份驗證

為了說明建立和安裝 handler,我們將使用 HTTPBasicAuthHandler。有關此主題的更詳細討論(包括對基本身份驗證工作原理的解釋),請參閱 基本身份驗證教程

當需要身份驗證時,伺服器會發送一個標頭(以及 401 錯誤程式碼),請求身份驗證。這指定了身份驗證方案和一個“realm”。標頭如下所示:WWW-Authenticate: SCHEME realm="REALM"

例如

WWW-Authenticate: Basic realm="cPanel Users"

然後,客戶端應重試請求,並在請求中的標頭中包含該 realm 的相應名稱和密碼。這就是“基本身份驗證”。為了簡化此過程,我們可以建立一個 HTTPBasicAuthHandler 的例項和一個 opener 來使用此 handler。

HTTPBasicAuthHandler 使用一個名為密碼管理器的物件來處理 URL 和 realm 到密碼和使用者名稱的對映。如果您知道 realm 是什麼(來自伺服器傳送的身份驗證標頭),則可以使用 HTTPPasswordMgr。通常,人們並不關心 realm 是什麼。在這種情況下,使用 HTTPPasswordMgrWithDefaultRealm 會很方便。這允許您為 URL 指定預設使用者名稱和密碼。如果您沒有為特定 realm 提供替代組合,則將提供此使用者名稱和密碼。我們透過向 add_password 方法提供 None 作為 realm 引數來表明這一點。

頂級 URL 是第一個需要身份驗證的 URL。與您傳遞給 .add_password() 的 URL 相比,“更深層”的 URL 也將匹配。

# create a password manager
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()

# Add the username and password.
# If we knew the realm, we could use it instead of None.
top_level_url = "http://example.com/foo/"
password_mgr.add_password(None, top_level_url, username, password)

handler = urllib.request.HTTPBasicAuthHandler(password_mgr)

# create "opener" (OpenerDirector instance)
opener = urllib.request.build_opener(handler)

# use the opener to fetch a URL
opener.open(a_url)

# Install the opener.
# Now all calls to urllib.request.urlopen use our opener.
urllib.request.install_opener(opener)

注意

在上面的示例中,我們僅將 HTTPBasicAuthHandler 提供給 build_opener。預設情況下,openers 具有用於正常情況的 handlers – ProxyHandler(如果設定了代理設定,例如 http_proxy 環境變數)、UnknownHandlerHTTPHandlerHTTPDefaultErrorHandlerHTTPRedirectHandlerFTPHandlerFileHandlerDataHandlerHTTPErrorProcessor

top_level_url 實際上是要麼一個完整的 URL(包括 'http:' 協議部分、主機名以及可選的埠號),例如 "http://example.com/" 要麼一個“權威”(即主機名,可選地包括埠號),例如 "example.com""example.com:8080"(後一個示例包括埠號)。如果存在,權威部分必須不包含“userinfo”元件 - 例如 "joe:password@example.com" 是不正確的。

代理

urllib 將自動檢測您的代理設定並使用它們。這是透過 ProxyHandler 實現的,它是檢測到代理設定時正常處理鏈的一部分。通常這是一件好事,但在某些情況下可能沒有幫助 [5]。一種方法是設定我們自己的 ProxyHandler,不定義任何代理。這與設定 基本身份驗證 處理程式類似。

>>> proxy_support = urllib.request.ProxyHandler({})
>>> opener = urllib.request.build_opener(proxy_support)
>>> urllib.request.install_opener(opener)

注意

目前 urllib.request 不支援透過代理獲取 https 位置。但是,可以透過擴充套件 urllib.request 來啟用此功能,如配方 [6] 中所示。

注意

如果設定了變數 REQUEST_METHOD,則會忽略 HTTP_PROXY;請參閱關於 getproxies() 的文件。

套接字和層

Python 對從 Web 獲取資源的支援是分層的。urllib 使用 http.client 庫,該庫又使用套接字型檔。

從 Python 2.3 開始,您可以指定套接字在超時之前應等待響應的時間。這在必須獲取網頁的應用程式中很有用。預設情況下,套接字模組沒有超時,可能會掛起。目前,套接字超時在 http.client 或 urllib.request 級別未公開。但是,您可以使用以下命令為所有套接字全域性設定預設超時:

import socket
import urllib.request

# timeout in seconds
timeout = 10
socket.setdefaulttimeout(timeout)

# this call to urllib.request.urlopen now uses the default timeout
# we have set in the socket module
req = urllib.request.Request('http://www.voidspace.org.uk')
response = urllib.request.urlopen(req)

腳註

本文件由 John Lee 審閱和修訂。