网络程序
虽然本书中的许多例子都侧重于读取文件并在这些文件中查找数据,但考虑到互联网,信息来源多种多样。
在本章中,我们将假装成一个网络浏览器,并使用超文本传输协议(HTTP)来检索网页。然后我们将通读网页数据并对其进行解析。
超文本传输协议 - HTTP
驱动网络的网络协议实际上相当简单,Python 中有一个名为 socket
的内置支持,这使得在 Python 程序中建立网络连接并通过这些套接字检索数据变得非常容易。
套接字 (socket) 非常像一个文件,不同之处在于单个套接字提供了两个程序之间的双向连接。你可以对同一个套接字进行读写操作。如果你向套接字写入内容,它会被发送到套接字另一端的应用程序。如果你从套接字读取内容,你会得到另一个应用程序发送的数据。
但是,如果你尝试读取一个套接字 1,而套接字另一端的程序尚未发送任何数据,你就会一直等待。如果套接字两端的程序都只是等待一些数据而不发送任何东西,它们将等待很长时间,因此通过互联网通信的程序的一个重要部分是拥有某种协议。
协议是一套精确的规则,它决定了谁先开始,他们要做什么,然后对该消息的响应是什么,谁接下来发送,等等。从某种意义上说,套接字两端的两个应用程序正在跳一支舞,并确保不踩到对方的脚。
有许多描述这些网络协议的文档。超文本传输协议在以下文档中进行了描述:
https://www.w3.org/Protocols/rfc2616/rfc2616.txt
这是一份长达 176 页、内容复杂且包含大量细节的文档。如果你觉得有趣,可以随意阅读全文。但是如果你看一下 RFC2616 的第 36 页左右,你会找到 GET 请求的语法。要从 Web 服务器请求文档,我们建立一个连接,例如连接到 www.pr4e.org
服务器的 80 端口,然后发送一行形式如下的内容
GET http://data.pr4e.org/romeo.txt HTTP/1.0
其中第二个参数是我们请求的网页,然后我们再发送一个空行。Web 服务器将响应一些关于文档的头部信息和一个空行,然后是文档内容。
世界上最简单的网络浏览器
也许展示 HTTP 协议如何工作的最简单方法是编写一个非常简单的 Python 程序,它连接到 Web 服务器并遵循 HTTP 协议的规则来请求文档并显示服务器发回的内容。
import socket
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mysock.connect(('data.pr4e.org', 80))
cmd = 'GET http://data.pr4e.org/romeo.txt HTTP/1.0\r\n\r\n'.encode()
mysock.send(cmd)
while True:
data = mysock.recv(512)
if len(data) < 1:
break
print(data.decode(),end='')
mysock.close()
# 代码: https://www.py4e.com/code3/socket1.py
首先,程序连接到服务器 data.pr4e.org
的 80 端口。由于我们的程序扮演“网络浏览器”的角色,HTTP 协议规定我们必须发送 GET 命令,后跟一个空行。\r\n
表示 EOL(行尾),所以 \r\n\r\n
表示两个 EOL 序列之间没有任何内容。这相当于一个空行。
套接字连接
发送那个空行后,我们编写一个循环,从套接字接收 512 字节块的数据并打印出来,直到没有更多数据可读(即 recv()
返回空字符串)。
该程序产生以下输出:
HTTP/1.1 200 OK
Date: Wed, 11 Apr 2018 18:52:55 GMT
Server: Apache/2.4.7 (Ubuntu)
Last-Modified: Sat, 13 May 2017 11:22:22 GMT
ETag: "a7-54f6609245537"
Accept-Ranges: bytes
Content-Length: 167
Cache-Control: max-age=0, no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Connection: close
Content-Type: text/plain
But soft what light through yonder window breaks
It is the east and Juliet is the sun
Arise fair sun and kill the envious moon
Who is already sick and pale with grief
输出以头部信息开始,Web 服务器发送这些信息来描述文档。例如,Content-Type
头部表示该文档是一个纯文本文档 (text/plain
)。
服务器发送完头部信息后,它会添加一个空行来表示头部的结束,然后发送文件 romeo.txt 的实际数据。
这个例子展示了如何使用套接字进行低级网络连接。套接字可用于与 Web 服务器、邮件服务器或许多其他类型的服务器通信。所需要的只是找到描述协议的文档,并根据协议编写发送和接收数据的代码。
然而,由于我们最常使用的协议是 HTTP Web 协议,Python 有一个专门设计用于支持通过 Web 检索文档和数据的 HTTP 协议的特殊库。
使用 HTTP 协议的要求之一是需要以字节对象(bytes objects)而不是字符串(strings)的形式发送和接收数据。在前面的例子中,encode()
和 decode()
方法将字符串转换为字节对象,反之亦然。
下一个例子使用 b''
表示法来指定一个变量应存储为字节对象。encode()
和 b''
是等效的。
>>> b'Hello world'
b'Hello world'
>>> 'Hello world'.encode()
b'Hello world'
通过 HTTP 检索图像
在上面的例子中,我们检索了一个包含换行符的纯文本文件,并在程序运行时简单地将数据复制到屏幕上。我们可以使用类似的程序通过 HTTP 检索图像。我们不是在程序运行时将数据复制到屏幕上,而是将数据累积在一个字符串中,去掉头部信息,然后将图像数据保存到一个文件中,如下所示:
import socket
import time
HOST = 'data.pr4e.org'
PORT = 80
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mysock.connect((HOST, PORT))
mysock.sendall(b'GET http://data.pr4e.org/cover3.jpg HTTP/1.0\r\n\r\n')
count = 0
picture = b""
while True:
data = mysock.recv(5120)
if len(data) < 1: break
#time.sleep(0.25)
count = count + len(data)
print(len(data), count)
picture = picture + data
mysock.close()
# 查找头部的结尾 (2 个 CRLF)
pos = picture.find(b"\r\n\r\n")
print('Header length', pos)
print(picture[:pos].decode())
# 跳过头部并保存图片数据
picture = picture[pos+4:]
fhand = open("stuff.jpg", "wb")
fhand.write(picture)
fhand.close()
# 代码: https://www.py4e.com/code3/urljpeg.py
当程序运行时,它产生以下输出:
$ python urljpeg.py
5120 5120
5120 10240
4240 14480
5120 19600
...
5120 214000
3200 217200
5120 222320
5120 227440
3167 230607
Header length 393
HTTP/1.1 200 OK
Date: Wed, 11 Apr 2018 18:54:09 GMT
Server: Apache/2.4.7 (Ubuntu)
Last-Modified: Mon, 15 May 2017 12:27:40 GMT
ETag: "38342-54f8f2e5b6277"
Accept-Ranges: bytes
Content-Length: 230210
Vary: Accept-Encoding
Cache-Control: max-age=0, no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Connection: close
Content-Type: image/jpeg
你可以看到,对于这个 url,Content-Type
头部表示文档的主体是一个图像 (image/jpeg
)。程序完成后,你可以通过在图像查看器中打开文件 stuff.jpg
来查看图像数据。
程序运行时,你可以看到我们每次调用 recv()
方法时并不会得到 5120 个字符。我们得到的是在调用 recv()
时,Web 服务器通过网络传输给我们的尽可能多的字符。在这个例子中,我们每次请求最多 5120 个字符的数据时,最少只得到 3200 个字符。
你的结果可能会因网络速度而异。另请注意,在最后一次调用 recv()
时,我们得到 3167 字节,这是数据流的末尾,在下一次调用 recv()
时,我们得到一个零长度字符串,这告诉我们服务器在其套接字端调用了 close()
,并且没有更多数据传来。
我们可以通过取消对 time.sleep()
调用的注释来减慢我们连续的 recv()
调用。这样,我们在每次调用后等待四分之一秒,以便服务器可以“领先”我们,并在我们再次调用 recv()
之前向我们发送更多数据。加入延迟后,程序的执行如下:
$ python urljpeg.py
5120 5120
5120 10240
5120 15360
...
5120 225280
5120 230400
207 230607
Header length 393
HTTP/1.1 200 OK
Date: Wed, 11 Apr 2018 21:42:08 GMT
Server: Apache/2.4.7 (Ubuntu)
Last-Modified: Mon, 15 May 2017 12:27:40 GMT
ETag: "38342-54f8f2e5b6277"
Accept-Ranges: bytes
Content-Length: 230210
Vary: Accept-Encoding
Cache-Control: max-age=0, no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Connection: close
Content-Type: image/jpeg
现在,除了第一次和最后一次调用 recv()
外,我们每次请求新数据时都能得到 5120 个字符。
在服务器发出 send()
请求和我们的应用程序发出 recv()
请求之间存在一个缓冲区。当我们在有延迟的情况下运行程序时,在某个时刻,服务器可能会填满套接字中的缓冲区,并被迫暂停,直到我们的程序开始清空缓冲区。发送应用程序或接收应用程序的暂停称为“流量控制”(flow control)。
使用 urllib
检索网页
虽然我们可以使用套接字库手动通过 HTTP 发送和接收数据,但在 Python 中执行这项常见任务有一种更简单的方法,即使用 urllib
库。
使用 urllib
,你可以像处理文件一样处理网页。你只需指明想要检索哪个网页,urllib
会处理所有的 HTTP 协议和头部细节。
使用 urllib
从 Web 读取 romeo.txt 文件的等效代码如下:
import urllib.request
fhand = urllib.request.urlopen('http://data.pr4e.org/romeo.txt')
for line in fhand:
print(line.decode().strip())
# 代码: https://www.py4e.com/code3/urllib1.py
一旦使用 urllib.request.urlopen
打开了网页,我们就可以像处理文件一样处理它,并使用 for
循环通读它。
当程序运行时,我们只看到文件内容的输出。头部信息仍然被发送,但 urllib
代码会消耗掉头部信息,只将数据返回给我们。
But soft what light through yonder window breaks
It is the east and Juliet is the sun
Arise fair sun and kill the envious moon
Who is already sick and pale with grief
举个例子,我们可以编写一个程序来检索 romeo.txt
的数据,并计算文件中每个单词的频率,如下所示:
import urllib.request, urllib.parse, urllib.error
fhand = urllib.request.urlopen('http://data.pr4e.org/romeo.txt')
counts = dict()
for line in fhand:
words = line.decode().split()
for word in words:
counts[word] = counts.get(word, 0) + 1
print(counts)
# 代码: https://www.py4e.com/code3/urlwords.py
再次强调,一旦我们打开了网页,我们就可以像读取本地文件一样读取它。
使用 urllib
读取二进制文件
有时你想检索非文本(或二进制)文件,例如图像或视频文件。这些文件中的数据通常打印出来没有用,但你可以使用 urllib
轻松地将 URL 的副本保存到你硬盘上的本地文件中。
模式是打开 URL 并使用 read
将文档的全部内容下载到一个字符串变量 (img
) 中,然后将该信息写入本地文件,如下所示:
import urllib.request, urllib.parse, urllib.error
img = urllib.request.urlopen('http://data.pr4e.org/cover3.jpg').read()
fhand = open('cover3.jpg', 'wb')
fhand.write(img)
fhand.close()
# 代码: https://www.py4e.com/code3/curl1.py
这个程序一次性通过网络读取所有数据,并将其存储在你计算机主内存的变量 img
中,然后打开文件 cover.jpg
并将数据写出到你的磁盘。用于 open()
的 wb
参数以仅写入模式打开二进制文件。如果文件大小小于你计算机的内存大小,这个程序就能工作。
然而,如果这是一个大型音频或视频文件,当你的计算机内存耗尽时,这个程序可能会崩溃,或者至少运行得极其缓慢。为了避免内存耗尽,我们分块(或缓冲)检索数据,然后在检索下一个块之前将每个块写入你的磁盘。这样,程序就可以读取任何大小的文件,而不会用尽你计算机中的所有内存。
import urllib.request, urllib.parse, urllib.error
img = urllib.request.urlopen('http://data.pr4e.org/cover3.jpg')
fhand = open('cover3.jpg', 'wb')
size = 0
while True:
info = img.read(100000)
if len(info) < 1: break
size = size + len(info)
fhand.write(info)
print(size, 'characters copied.')
fhand.close()
# 代码: https://www.py4e.com/code3/curl2.py
在这个例子中,我们一次只读取 100,000 个字符,然后将这些字符写入 cover3.jpg
文件,之后再从 Web 检索接下来的 100,000 个字符的数据。
这个程序运行如下:
python curl2.py
230210 characters copied.
解析 HTML 和抓取网页
Python 中 urllib
功能的一个常见用途是抓取 (scrape) 网页。网络抓取是指我们编写一个程序,假装成网络浏览器并检索页面,然后检查这些页面中的数据以寻找模式。
例如,像 Google 这样的搜索引擎会查看一个网页的源代码,提取指向其他页面的链接并检索那些页面,再提取链接,依此类推。使用这种技术,Google 爬行 (spiders) 遍历了网络上几乎所有的页面。
Google 还使用从它找到的页面指向特定页面的链接频率,作为衡量该页面“重要性”以及该页面应在其搜索结果中排名多高的一种指标。
使用正则表达式解析 HTML
解析 HTML 的一种简单方法是使用正则表达式重复搜索和提取匹配特定模式的子字符串。
这是一个简单的网页:
<h1>The First Page</h1>
<p>
If you like, you can switch to the
<a href="http://www.dr-chuck.com/page2.htm">
Second Page</a>.
</p>
我们可以构建一个格式良好的正则表达式来匹配并从上述文本中提取链接值,如下所示:
href="http[s]?://.+?"
我们的正则表达式查找以 “href="http://"” 或 “href="https://”” 开头,后跟一个或多个字符 (.+?
),再后跟另一个双引号的字符串。[s]?
后面的问号表示搜索字符串“http”后跟零个或一个“s”。
添加到 .+?
的问号表示匹配应以“非贪婪”方式而不是“贪婪”方式进行。非贪婪匹配尝试找到最小可能匹配的字符串,而贪婪匹配尝试找到最大可能匹配的字符串。
我们在正则表达式中添加圆括号来指示我们想要提取匹配字符串的哪个部分,并生成以下程序:
# 在 URL 输入中搜索链接值
import urllib.request, urllib.parse, urllib.error
import re
import ssl
# 忽略 SSL 证书错误
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
url = input('Enter - ')
html = urllib.request.urlopen(url, context=ctx).read()
links = re.findall(b'href="(http[s]?://.*?)"', html)
for link in links:
print(link.decode())
# 代码: https://www.py4e.com/code3/urlregex.py
ssl
库允许此程序访问严格执行 HTTPS 的网站。read
方法返回 HTML 源代码作为字节对象,而不是返回 HTTPResponse 对象。findall
正则表达式方法将给我们一个包含所有匹配我们正则表达式的字符串的列表,只返回双引号之间的链接文本。
当我们运行程序并输入一个 URL 时,我们得到以下输出:
Enter - https://docs.python.org
https://docs.python.org/3/index.html
https://www.python.org/
https://docs.python.org/3.8/
https://docs.python.org/3.7/
https://docs.python.org/3.5/
https://docs.python.org/2.7/
https://www.python.org/doc/versions/
https://www.python.org/dev/peps/
https://wiki.python.org/moin/BeginnersGuide
https://wiki.python.org/moin/PythonBooks
https://www.python.org/doc/av/
https://www.python.org/
https://www.python.org/psf/donations/
http://sphinx.pocoo.org/
正则表达式在你的 HTML 格式良好且可预测时效果很好。但是由于存在许多“损坏的”HTML 页面,仅使用正则表达式的解决方案可能会遗漏一些有效链接或最终得到错误数据。
这可以通过使用健壮的 HTML 解析库来解决。
使用 BeautifulSoup 解析 HTML
尽管 HTML 看起来像 XML 2 并且有些页面被精心构建成 XML,但大多数 HTML 通常存在缺陷,导致 XML 解析器将整个 HTML 页面视为格式不正确而拒绝。
有许多 Python 库可以帮助你解析 HTML 并从页面中提取数据。每个库都有其优点和缺点,你可以根据需要选择一个。
举个例子,我们将简单地解析一些 HTML 输入,并使用 BeautifulSoup 库提取链接。BeautifulSoup 能够容忍高度有缺陷的 HTML,并且仍然让你能够轻松地提取你需要的数据。你可以从以下地址下载并安装 BeautifulSoup 代码:
https://pypi.python.org/pypi/beautifulsoup4
有关使用 Python 包索引工具 pip
安装 BeautifulSoup 的信息,请访问:
https://packaging.python.org/tutorials/installing-packages/
我们将使用 urllib
来读取页面,然后使用 BeautifulSoup
来提取锚点(a
)标签中的 href
属性。
# 要运行此程序,请下载 BeautifulSoup zip 文件
# http://www.py4e.com/code3/bs4.zip
# 并在与此文件相同的目录中解压缩
import urllib.request, urllib.parse, urllib.error
from bs4 import BeautifulSoup
import ssl
# 忽略 SSL 证书错误
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
url = input('Enter - ')
html = urllib.request.urlopen(url, context=ctx).read()
soup = BeautifulSoup(html, 'html.parser')
# 检索所有锚点标签
tags = soup('a')
for tag in tags:
print(tag.get('href', None))
# 代码: https://www.py4e.com/code3/urllinks.py
程序提示输入一个网址,然后打开该网页,读取数据并将数据传递给 BeautifulSoup 解析器,然后检索所有锚点标签并打印出每个标签的 href
属性。
当程序运行时,它产生以下输出:
Enter - https://docs.python.org
genindex.html
py-modindex.html
https://www.python.org/
#
whatsnew/3.6.html
whatsnew/index.html
tutorial/index.html
library/index.html
reference/index.html
using/index.html
howto/index.html
installing/index.html
distributing/index.html
extending/index.html
c-api/index.html
faq/index.html
py-modindex.html
genindex.html
glossary.html
search.html
contents.html
bugs.html
about.html
license.html
copyright.html
download.html
https://docs.python.org/3.8/
https://docs.python.org/3.7/
https://docs.python.org/3.5/
https://docs.python.org/2.7/
https://www.python.org/doc/versions/
https://www.python.org/dev/peps/
https://wiki.python.org/moin/BeginnersGuide
https://wiki.python.org/moin/PythonBooks
https://www.python.org/doc/av/
genindex.html
py-modindex.html
https://www.python.org/
#
copyright.html
https://www.python.org/psf/donations/
bugs.html
http://sphinx.pocoo.org/
这个列表要长得多,因为一些 HTML 锚点标签是相对路径(例如,tutorial/index.html)或页内引用(例如,‘#’),它们不包含“http://”或“https://”,这在我们的正则表达式中是必需的。
你也可以使用 BeautifulSoup 来提取每个标签的各个部分:
# 要运行此程序,请下载 BeautifulSoup zip 文件
# http://www.py4e.com/code3/bs4.zip
# 并在与此文件相同的目录中解压缩
from urllib.request import urlopen
from bs4 import BeautifulSoup
import ssl
# 忽略 SSL 证书错误
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
url = input('Enter - ')
html = urlopen(url, context=ctx).read()
soup = BeautifulSoup(html, "html.parser")
# 检索所有锚点标签
tags = soup('a')
for tag in tags:
# 查看标签的各个部分
print('TAG:', tag)
print('URL:', tag.get('href', None))
print('Contents:', tag.contents[0])
print('Attrs:', tag.attrs)
# 代码: https://www.py4e.com/code3/urllink2.py
python urllink2.py
Enter - http://www.dr-chuck.com/page1.htm
TAG: <a href="http://www.dr-chuck.com/page2.htm">
Second Page</a>
URL: http://www.dr-chuck.com/page2.htm
Content: ['\nSecond Page']
Attrs: [('href', 'http://www.dr-chuck.com/page2.htm')]
html.parser
是标准 Python 3 库中包含的 HTML 解析器。有关其他 HTML 解析器的信息,请访问:
http://www.crummy.com/software/BeautifulSoup/bs4/doc/#installing-a-parser
这些例子仅仅开始展示 BeautifulSoup 在解析 HTML 方面的强大功能。
Unix / Linux 用户的额外部分
如果你有 Linux、Unix 或 Macintosh 计算机,你的操作系统中可能内置了使用 HTTP 或文件传输协议 (FTP) 检索纯文本和二进制文件的命令。其中一个命令是 curl
:
$ curl -O http://www.py4e.com/cover.jpg
命令 curl
是“copy URL”(复制 URL)的缩写,因此前面列出的使用 urllib
检索二进制文件的两个例子在 www.py4e.com/code3 上巧妙地命名为 curl1.py
和 curl2.py
,因为它们实现了与 curl
命令类似的功能。还有一个 curl3.py
示例程序能更有效地完成这项任务,以防你实际想在你编写的程序中使用这种模式。
第二个功能非常相似的命令是 wget
:
$ wget http://www.py4e.com/cover.jpg
这两个命令都使检索网页和远程文件成为一项简单的任务。
术语表
BeautifulSoup 一个用于解析 HTML 文档并从 HTML 文档中提取数据的 Python 库,它可以弥补浏览器通常会忽略的 HTML 中的大多数缺陷。你可以从 www.crummy.com 下载 BeautifulSoup 代码。 端口 (port) 当你与服务器建立套接字连接时,通常指示你正在联系哪个应用程序的数字。例如,Web 流量通常使用 80 端口,而电子邮件流量使用 25 端口。 抓取 (scrape) 指程序假装成网络浏览器并检索网页,然后查看网页内容。程序通常会跟踪一个页面中的链接以查找下一个页面,从而遍历页面网络或社交网络。 套接字 (socket) 两个应用程序之间的网络连接,应用程序可以在任一方向发送和接收数据。 爬虫程序 (spider) 网络搜索引擎检索一个页面,然后检索该页面链接的所有页面,依此类推,直到它们拥有互联网上几乎所有的页面,然后用这些页面来构建它们的搜索索引的行为。
练习
练习 1: 更改套接字程序 socket1.py
,使其提示用户输入 URL,以便它可以读取任何网页。
你可以使用 split('/')
将 URL 分解为其组成部分,以便为套接字 connect
调用提取主机名。使用 try
和 except
添加错误检查,以处理用户输入格式不正确或不存在的 URL 的情况。
练习 2: 更改你的套接字程序,使其计算已接收的字符数,并在显示 3000 个字符后停止显示任何文本。程序应检索整个文档,计算总字符数,并在文档末尾显示字符数。
练习 3: 使用 urllib
复制上一个练习的内容:(1)从 URL 检索文档,(2)显示最多 3000 个字符,以及(3)计算文档中的总字符数。对于本练习,不必担心头部信息,只需显示文档内容的前 3000 个字符。
练习 4: 更改 urllinks.py
程序,以从检索到的 HTML 文档中提取并计数段落(p)标签,并将段落计数显示为程序的输出。不要显示段落文本,只需计数。在几个小型网页以及一些较大型网页上测试你的程序。
练习 5: (高级)更改套接字程序,使其仅在接收到头部信息和空行之后才显示数据。请记住,recv
接收的是字符(包括换行符等),而不是行。
- 如果你想了解更多关于套接字、协议或 Web 服务器开发的信息,可以探索 https://www.dj4e.com 上的课程。 ↩︎
- XML 格式将在下一章中描述。 ↩︎
如果你在本书中发现错误,欢迎使用 Github 给我发送修正。