首页 > Python > Python 3 标准库实例教程 > 互联网数据处理

13.3. urllib.request — 访问网络资源

目标:一个用于访问 URLs 资源的库,且可以通过自定义协议处理程序来扩展。

urllib.request 模块提供了一个使用由 URLs 指定的网络资源的 API。它被设计成是可被用户应用扩展的,以便支持新的协议或者添加现存协议的变种(比如处理 HTTP 基本认证)。

HTTP GET

注意

为测试本节的例子所使用的服务器由 http_server_GET.py 实现,事实上这是为了介绍 http.server 模块而编写的几个例子。在一个终端开启这个服务器,然后就可以在另一个终端下尝试本节的例子了。

HTTP GET 操作是 urllib.request 最简单的一个用法。通过给 urlopen() 函数传递一个 URL 地址获得一个「类文件」句柄来操作远程资料。

urllib_request_urlopen.py

from urllib import request

response = request.urlopen('http://localhost:8080/')
print('RESPONSE:', response)
print('URL     :', response.geturl())

headers = response.info()
print('DATE    :', headers['date'])
print('HEADERS :')
print('---------')
print(headers)

data = response.read().decode('utf-8')
print('LENGTH  :', len(data))
print('DATA    :')
print('---------')
print(data)

我们的范例服务器接收输入,然后返回一串文本作为应答。从 urlopen() 返回的对象的方法 info() 让我们得以访问 HTTP 服务器的响应头信息,而远程资源的其他数据则可以通过 read() 和 readlines() 方法取得。

$ python3 urllib_request_urlopen.py

RESPONSE: <http.client.HTTPResponse object at 0x101744d68>
URL     : http://localhost:8080/
DATE    : Sat, 08 Oct 2016 18:08:54 GMT
HEADERS :
---------
Server: BaseHTTP/0.6 Python/3.5.2
Date: Sat, 08 Oct 2016 18:08:54 GMT
Content-Type: text/plain; charset=utf-8

LENGTH  : 349
DATA    :
---------
CLIENT VALUES:
client_address=('127.0.0.1', 58420) (127.0.0.1)
command=GET
path=/
real path=/
query=
request_version=HTTP/1.1

SERVER VALUES:
server_version=BaseHTTP/0.6
sys_version=Python/3.5.2
protocol_version=HTTP/1.0

HEADERS RECEIVED:
Accept-Encoding=identity
Connection=close
Host=localhost:8080
User-Agent=Python-urllib/3.5

这个由 urlopen() 返回的类文件对象是可迭代的:

urllib_request_urlopen_iterator.py

from urllib import request

response = request.urlopen('http://localhost:8080/')
for line in response:
    print(line.decode('utf-8').rstrip())

这个例子在打印输出之前去掉了每一行末尾的换行符与回车符。

$ python3 urllib_request_urlopen_iterator.py

CLIENT VALUES:
client_address=('127.0.0.1', 58444) (127.0.0.1)
command=GET
path=/
real path=/
query=
request_version=HTTP/1.1

SERVER VALUES:
server_version=BaseHTTP/0.6
sys_version=Python/3.5.2
protocol_version=HTTP/1.0

HEADERS RECEIVED:
Accept-Encoding=identity
Connection=close
Host=localhost:8080
User-Agent=Python-urllib/3.5

编码参数

参数用 urllib.parse.urlencode() 编码后就可以添加到 URL ,然后传递给服务器。

urllib_request_http_get_args.py

from urllib import parse
from urllib import request

query_args = {'q': 'query string', 'foo': 'bar'}
encoded_args = parse.urlencode(query_args)
print('Encoded:', encoded_args)

url = 'http://localhost:8080/?' + encoded_args
print(request.urlopen(url).read().decode('utf-8'))

以上例子返回的输出列表 CLIENT VALUES 包含编码后的查询参数。

$ python urllib_request_http_get_args.py
Encoded: q=query+string&foo=bar
CLIENT VALUES:
client_address=('127.0.0.1', 58455) (127.0.0.1)
command=GET
path=/?q=query+string&foo=bar
real path=/
query=q=query+string&foo=bar
request_version=HTTP/1.1

SERVER VALUES:
server_version=BaseHTTP/0.6
sys_version=Python/3.5.2
protocol_version=HTTP/1.0

HEADERS RECEIVED:
Accept-Encoding=identity
Connection=close
Host=localhost:8080
User-Agent=Python-urllib/3.5

HTTP POST

注意

测试例子所用的服务器在 http_server_POST.py 中实现,这主要是在介绍 http.server 模块时实现的几个例子。在一个终端开启这个服务器,然后在另一个终端测试本节的例子。

想要用 POST 的方式而不是 GET 方式提交形式编码后的数据到远端的服务器,则需要将编码后的查询参数作为数据传递给 urlopen() 函数。

urllib_request_urlopen_post.py

from urllib import parse
from urllib import request

query_args = {'q': 'query string', 'foo': 'bar'}
encoded_args = parse.urlencode(query_args).encode('utf-8')
url = 'http://localhost:8080/'
print(request.urlopen(url, encoded_args).read().decode('utf-8'))

服务器可以解码形式编码后的数据并按名称获取对应的值。

$ python3 urllib_request_urlopen_post.py

Client: ('127.0.0.1', 58568)
User-agent: Python-urllib/3.5
Path: /
Form data:
    foo=bar
    q=query string

添加输出信息的头部

urlopen() 是一个方便我们使用的函数,该函数包装了一些请求 (request) 是如何创建和操作的细节。更精准的控制可以直接用 Request 的实例来实现。比如说,在输出信息中添加自定义头部来控制返回数据的格式,指出本地缓存文件的版本,并告知远端服务器正在交互的客户端软件的名字。

由前面的例子的输出可以看出,头部中默认的 User-agent 值是由常量 Python-urllib 紧跟 Python 解释器版本组成的。当你开发的应用需要访问属于其他人的网络资源时,出于礼貌,应该在请求中包含实际的 用户 agent 信息,以便对方可以更容易地识别访问源。使用自定义的 agent 也使得对方可以用 robots.txt 文件(参考 http.robotparser 模块)来对爬虫进行控制。

urllib_request_request_header.py

from urllib import request

r = request.Request('http://localhost:8080/')
r.add_header(
    'User-agent',
    'PyMOTW (https://pymotw.com/)',
)

response = request.urlopen(r)
data = response.read().decode('utf-8')
print(data)

在创建了一个 Request 对象后,并在发送这个请求前,请使用 add_header() 来设定用户 agent 的值。最后一行的输出展示了我们自定义的值。

$ python3 urllib_request_request_header.py

CLIENT VALUES:
client_address=('127.0.0.1', 58585) (127.0.0.1)
command=GET
path=/
real path=/
query=
request_version=HTTP/1.1

SERVER VALUES:
server_version=BaseHTTP/0.6
sys_version=Python/3.5.2
protocol_version=HTTP/1.0

HEADERS RECEIVED:
Accept-Encoding=identity
Connection=close
Host=localhost:8080
User-Agent=PyMOTW (https://pymotw.com/)

用 Request 以 POST 方式发送表单数据

需要被传递的数据可以在构建 Request 时特别指定,以便用 POST 方法发送给服务器。

urllib_request_request_post.py

from urllib import parse
from urllib import request

query_args = {'q': 'query string', 'foo': 'bar'}

r = request.Request(
    url='http://localhost:8080/',
    data=parse.urlencode(query_args).encode('utf-8'),
)
print('Request method :', r.get_method())
r.add_header(
    'User-agent',
    'PyMOTW (https://pymotw.com/)',
)

print()
print('OUTGOING DATA:')
print(r.data)

print()
print('SERVER RESPONSE:')
print(request.urlopen(r).read().decode('utf-8'))

在指定添加数据以后, Request 所使用的 HTTP 方法自动从 GET 变为 POST 。

$ python3 urllib_request_request_post.py

Request method : POST

OUTGOING DATA:
b'q=query+string&foo=bar'

SERVER RESPONSE:
Client: ('127.0.0.1', 58613)
User-agent: PyMOTW (https://pymotw.com/)
Path: /
Form data:
    foo=bar
    q=query string

上传文件

编码并上传文件比操作简单表单的工作要多一些。一个完整的 MIME 消息需要在 request 体内构建,以便服务器可以区分表单字段和要上传的文件。

urllib_request_upload_files.py

import io
import mimetypes
from urllib import request
import uuid

class MultiPartForm:
    """积累数据以便在 POST 一个表单的时候用"""

    def __init__(self):
        self.form_fields = []
        self.files = []
        # 使用一个大随机字节串来划分 MIME 数据的各部分。
        self.boundary = uuid.uuid4().hex.encode('utf-8')
        return

    def get_content_type(self):
        return 'multipart/form-data; boundary={}'.format(
            self.boundary.decode('utf-8'))

    def add_field(self, name, value):
        """向表单数据增加一个简单字段。"""
        self.form_fields.append((name, value))

    def add_file(self, fieldname, filename, fileHandle,
                 mimetype=None):
        """添加一个要上传的文件。"""
        body = fileHandle.read()
        if mimetype is None:
            mimetype = (
                mimetypes.guess_type(filename)[0] or
                'application/octet-stream'
            )
        self.files.append((fieldname, filename, mimetype, body))
        return

    @staticmethod
    def _form_data(name):
        return ('Content-Disposition: form-data; '
                'name="{}"\r\n').format(name).encode('utf-8')

    @staticmethod
    def _attached_file(name, filename):
        return ('Content-Disposition: file; '
                'name="{}"; filename="{}"\r\n').format(
                    name, filename).encode('utf-8')

    @staticmethod
    def _content_type(ct):
        return 'Content-Type: {}\r\n'.format(ct).encode('utf-8')

    def __bytes__(self):
        """返回一个表示表单数据的字节串,包括附加的文件。"""
        buffer = io.BytesIO()
        boundary = b'--' + self.boundary + b'\r\n'

        # 添加表单字段
        for name, value in self.form_fields:
            buffer.write(boundary)
            buffer.write(self._form_data(name))
            buffer.write(b'\r\n')
            buffer.write(value.encode('utf-8'))
            buffer.write(b'\r\n')

        # 添加要上传的文件
        for f_name, filename, f_content_type, body in self.files:
            buffer.write(boundary)
            buffer.write(self._attached_file(f_name, filename))
            buffer.write(self._content_type(f_content_type))
            buffer.write(b'\r\n')
            buffer.write(body)
            buffer.write(b'\r\n')

        buffer.write(b'--' + self.boundary + b'--\r\n')
        return buffer.getvalue()

if __name__ == '__main__':
    # 创建带有简单字段的表单
    form = MultiPartForm()
    form.add_field('firstname', 'Doug')
    form.add_field('lastname', 'Hellmann')

    # 添加一个伪文件
    form.add_file(
        'biography', 'bio.txt',
        fileHandle=io.BytesIO(b'Python developer and blogger.'))

    # 构建一个要提交 (POST) 的字节串数据的请求 (request) 。
    data = bytes(form)
    r = request.Request('http://localhost:8080/', data=data)
    r.add_header(
        'User-agent',
        'PyMOTW (https://pymotw.com/)',
    )
    r.add_header('Content-type', form.get_content_type())
    r.add_header('Content-length', len(data))

    print()
    print('OUTGOING DATA:')
    for name, value in r.header_items():
        print('{}: {}'.format(name, value))
    print()
    print(r.data.decode('utf-8'))

    print()
    print('SERVER RESPONSE:')
    print(request.urlopen(r).read().decode('utf-8'))

MultiPartForm 类可以将任意一个表单表示成一个附有文件的具有多个部分的 MIME 消息。

$ python3 urllib_request_upload_files.py

OUTGOING DATA:
User-agent: PyMOTW (https://pymotw.com/)
Content-type: multipart/form-data;
    boundary=d99b5dc60871491b9d63352eb24972b4
Content-length: 389

--d99b5dc60871491b9d63352eb24972b4
Content-Disposition: form-data; name="firstname"

Doug
--d99b5dc60871491b9d63352eb24972b4
Content-Disposition: form-data; name="lastname"

Hellmann
--d99b5dc60871491b9d63352eb24972b4
Content-Disposition: file; name="biography";
    filename="bio.txt"
Content-Type: text/plain

Python developer and blogger.
--d99b5dc60871491b9d63352eb24972b4--

SERVER RESPONSE:
Client: ('127.0.0.1', 59310)
User-agent: PyMOTW (https://pymotw.com/)
Path: /
Form data:
    Uploaded biography as 'bio.txt' (29 bytes)
    firstname=Doug
    lastname=Hellmann

创建自定义协议处理器

urllib.request 內建支持访问 HTTP(S) ,FTP ,和本地文件。要添加对其他 URL 类型的支持,就得先注册另一个协议处理器。比如说,要支持用 URLs 来指向远端 NFS 服务器上的任意文件,又不需要用户在访问文件前挂载路径,就需要创建一个从 BaseHandler 派生的,定义有 nfs_open() 方法的子类。

按协议指定的 open() 方法仅有一个参数,即一个 Request 实例,该方法返回一个对象, 该对象须定义有一个读取数据的 read() 方法,一个返回回复头部信息的 info() 方法, 和一个返回被读取文件实际所在 URL 的 geturl() 方法。一个实现上述要求的简单方案是创建 urllib.response.addinfourl 的一个实例,并将头部 (headers) ,URL 和文件开启句柄 (open file handle) 传递给该实例的构建函数。

urllib_request_nfs_handler.py

import io
import mimetypes
import os
import tempfile
from urllib import request
from urllib import response

class NFSFile:

    def __init__(self, tempdir, filename):
        self.tempdir = tempdir
        self.filename = filename
        with open(os.path.join(tempdir, filename), 'rb') as f:
            self.buffer = io.BytesIO(f.read())

    def read(self, *args):
        return self.buffer.read(*args)

    def readline(self, *args):
        return self.buffer.readline(*args)

    def close(self):
        print('\nNFSFile:')
        print('  unmounting {}'.format(
            os.path.basename(self.tempdir)))
        print('  when {} is closed'.format(
            os.path.basename(self.filename)))

class FauxNFSHandler(request.BaseHandler):

    def __init__(self, tempdir):
        self.tempdir = tempdir
        super().__init__()

    def nfs_open(self, req):
        url = req.full_url
        directory_name, file_name = os.path.split(url)
        server_name = req.host
        print('FauxNFSHandler simulating mount:')
        print('  Remote path: {}'.format(directory_name))
        print('  Server     : {}'.format(server_name))
        print('  Local path : {}'.format(
            os.path.basename(tempdir)))
        print('  Filename   : {}'.format(file_name))
        local_file = os.path.join(tempdir, file_name)
        fp = NFSFile(tempdir, file_name)
        content_type = (
            mimetypes.guess_type(file_name)[0] or
            'application/octet-stream'
        )
        stats = os.stat(local_file)
        size = stats.st_size
        headers = {
            'Content-type': content_type,
            'Content-length': size,
        }
        return response.addinfourl(fp, headers,
                                   req.get_full_url())

if __name__ == '__main__':
    with tempfile.TemporaryDirectory() as tempdir:
        # 创建一个临时文件来测试
        filename = os.path.join(tempdir, 'file.txt')
        with open(filename, 'w', encoding='utf-8') as f:
            f.write('Contents of file.txt')

        # 用我们的 NFS 处理器构建一个开启者
        # 并将其注册为默认的开启者。
        opener = request.build_opener(FauxNFSHandler(tempdir))
        request.install_opener(opener)

        # 通过 URL 打开这个文件。
        resp = request.urlopen(
            'nfs://remote_server/path/to/the/file.txt'
        )
        print()
        print('READ CONTENTS:', resp.read())
        print('URL          :', resp.geturl())
        print('HEADERS:')
        for name, value in sorted(resp.info().items()):
            print('  {:<15} = {}'.format(name, value))
        resp.close()

FauxNFSHandler 和 NFSFile 类分别打印信息来演示实现中实际呼叫挂载和卸载的地方。 由于这仅仅是一个模拟,我们仅仅向 FauxNFSHandler 提供临时文件夹的路径,它需要查看其中所有的文件。

$ python3 urllib_request_nfs_handler.py

FauxNFSHandler simulating mount:
  Remote path: nfs://remote_server/path/to/the
  Server     : remote_server
  Local path : tmprucom5sb
  Filename   : file.txt

READ CONTENTS: b'Contents of file.txt'
URL          : nfs://remote_server/path/to/the/file.txt
HEADERS:
  Content-length  = 20
  Content-type    = text/plain

NFSFile:
  unmounting tmprucom5sb
  when file.txt is closed

参考

  • 标准库 urllib.request 文档
  • urllib.parse -- 可用于处理 URL 字符串本身。
  • Form content types -- 通过 HTTP 表单来提交文件或大规模数据的 W3C 说明标准。
  • mimetypes -- 名称到 mimetype 的映射。
  • requests -- 一个提供更多安全链接支持和更易用的 API 的第三方 HTTP 库。Python 核心开发组建议大多数开发人员使用 requests,部分由于其比标准库更常得到安全方面的更新。