网页抓取是通过程序下载网页并从中提取信息的过程。 这种技术很有用,在网页中有需要在程序中使用的信息时,就可使用它。 当然,如果网页是动态的,即随时间而变化,这就更有用了。 如果网页不是动态的,可手工下载一次并提取其中的信息。 (当然,最理想的情况是,可通过 Web服务来获取这些信息,这将在本章后面讨论。)
从概念上说,这种技术非常简单:下载数据并对其进行分析。
例如,可使用urllib来获取网页的HTML代码,再使用正则表达式或其他技术从中提取信息。
例如,假设要从Python Job Board(http://python.org/jobs )提取招聘单位的名称和网站。
通过查看该网页的源代码,发现可在类似于下面的链接中找到名称和URL:
下面示例程序使用urllib
和re
来提取所需的信息。
import requests
import re
response = requests.get('http://python.org/jobs')
response.raise_for_status() # 检查请求是否成功
p = re.compile('<a href="(/jobs/\\d+)/">(.*?)</a>')
for url, name in p.findall(response.text):
print(f"{name} (https://python.org{url})")
Senior Scientific Software Engineer (https://python.org/jobs/7846) Core Tech AI Engineer at CodeFlash.ai (https://python.org/jobs/7844) Python Software Engineer (Docker required) (https://python.org/jobs/7843) Senior Data Analyst (https://python.org/jobs/7842) Senior Python Engineer (https://python.org/jobs/7841) Senior Full Stack Web Developer (Python/Django + CMS) (https://python.org/jobs/7840) Python Software Engineer (https://python.org/jobs/7836) Senior Platform Engineer (https://python.org/jobs/7835) Drone Python Programmer/Software Engineer (https://python.org/jobs/7834) Senior Back-End Engineer (Python) (https://python.org/jobs/7832) Senior Software Engineer (https://python.org/jobs/7830) Senior Python Full Stack Developer in Canada. (100% Remote) (https://python.org/jobs/7829) Python React Developer (https://python.org/jobs/7828) Full Stack Engineer (Django) (https://python.org/jobs/7827) Python/C++ Developer (https://python.org/jobs/7826) Senior Python Backend Developer with Blockchain Experience (https://python.org/jobs/7824) Python/MATLAB Programmer (https://python.org/jobs/7823) Senior Python Backend Engineer (https://python.org/jobs/7821) Lead Python Backend Engineer (https://python.org/jobs/7820) Python Odoo programer needed (https://python.org/jobs/7787) Sr. Software Engineer - R&D Modeling and Simulation (https://python.org/jobs/7752) Python Annotator (https://python.org/jobs/7750) Senior Backend Engineer (https://python.org/jobs/7749) [Hiring] Software Engineers to Train Advanced AI Models (Remote, Flexible Hours) (https://python.org/jobs/7748)
这些代码当然有改进的空间,但已经做得非常出色了。 不过这种方法至少存在3个缺点。
- 正则表达式一点都不容易理解。如果HTML代码和查询都更复杂, 正则表达式将更难以理 解和维护。
- 它对付不了独特的HTML内容,如CDATA部分和字符实体(如&)。 遇到这样的东西 时,这个程序很可能束手无策。
正则表达式依赖于HTML代码的细节,而不是更抽象的结构。 这意味着只要网页的结构发生 细微的变化,这个程序可能就不管用(等阅读本书时,它可能已经不管用了)。
针对基于正则表达式的方法存在的问题,接下来将讨论两种可能的解决方案。 一是结合使用程序Tidy(一个Python库)和XHTML解析; 二是使用专为网页抓取而设计的Beautiful Soup库。
注意:还有其他Python网页抓取工具。例如,
可能想查看 Ka-Ping Yee
的scrape.py
(http://zesty.ca/python )。
Python标准库为解析HTML和XML等结构化格式提供了强大的支持。 XML和XML解析将在后面更深入地讨论, 这里只介绍处理XHTML所需的工具。 XHTML是HTML 规范描述的两种具体语法之一, 也是一 种XML格式。 这里介绍的大部分内容也适用于HTML。
如果每个网页包含的XHTML都正确而有效,解析工作将非常简单。 问题是较老的HTML方言不那么严谨,虽然有人指责这些不严谨的方言, 但有些人对这些指责置若罔闻。原因可能在于大多数Web浏览器都非常宽容, 即便面对的是最混乱、最无意义的HTML,也会尽最大努力将其渲染出来。 这为网页制作者提供了方便,可能让他们感到满意,却让网页抓取工作变得难得多。
标准库提供的通用的HTML解析方法是基于事件的:
编写事件处理程序,供解析程序处理 数据时调用。
标准库模块html.parser
能够以这种方式对极不严谨的HTML进行解析,
但要基于文档结构来提取数据(如第二个二级标题后面的第一项),
在存在标签缺失的情况下恐怕就只能靠猜了。如果愿意,
当然可以这样做,但还有另一种方式:使用Tidy。
Tidy是用于对格式不正确且不严谨的HTML进行修复的工具。 它非常聪明,能够修复很多常 见的错误,从而完成大量不愿意做的工作。 它还提供了极大的配置空间,能够开/关各种校正。
下面是一个错误百出的HTML文件,有些过时的HTML代码, 还有些明显的错误(能找出所有的问题吗):
ugly_html = '''
<h1>Pet Shop
<h2>Complaints</h3>
<p>There is <b>no <i>way</b> at all</i> we can accept returned
parrots.
<h1><i>Dead Pets</h1>
<p>Our pets may tend to rest at times, but rarely die within the
warranty period.
<i><h2>News</h2></i>
<p>We have just received <b>a really nice parrot.
<p>It's really nice.</b>
<h3><hr>The Norwegian Blue</h3>
<h4>Plumage and <hr>pining behavior</h4>
<a href="#norwegian-blue">More information<a>
<p>Features:
<body>
<li>Beautiful plumage
'''
with open('/tmp/ugly.html', 'w') as fo:
fo.write(ugly_html)
下面是Tidy修复后的版本: 当然,Tidy并不能修复HTML文件存在的所有问题, 但确实能够确保文件是格式良好的(即所有元素都嵌套正确), 这让解析工作容易得多。
有多个用于Python的Tidy库包装器,至于哪个最新并非固定不变的。可像下面这样使用pip 来找出可供使用的包装器:
$ pip search tidy
一个不错的选择是PyTidyLib,可像下面这样安装它:
$ pip install pytidylib
然而,并非一定要安装Tidy库包装器。如果使用的是UNIX或Linux系统, 很可能已安装了命令行版Tidy。 另外,不管使用的是哪种操作系统, 都可从Tidy网站(http://html-tidy.org )获取可执行的二进制版本。 有了二进制版本后,就可使用模块subprocess(或其他包含popen函数的模块)来运行Tidy程序了。 例如,假设有一个混乱的HTML文件(messy.html), 且在执行路径中包含命令行版Tidy。
# pip install pytidylib
下面的程序将对这个文件运行Tidy并将结果打印出来:
from subprocess import Popen, PIPE
text = open('/tmp/ugly.html').read()
tidy = Popen('tidy', stdin=PIPE, stdout=PIPE, stderr=PIPE)
tidy.stdin.write(text.encode())
tidy.stdin.close()
print(tidy.stdout.read().decode())
--------------------------------------------------------------------------- FileNotFoundError Traceback (most recent call last) Cell In[6], line 4 1 from subprocess import Popen, PIPE 3 text = open('/tmp/ugly.html').read() ----> 4 tidy = Popen('tidy', stdin=PIPE, stdout=PIPE, stderr=PIPE) 6 tidy.stdin.write(text.encode()) 7 tidy.stdin.close() File /opt/conda/lib/python3.12/subprocess.py:1026, in Popen.__init__(self, args, bufsize, executable, stdin, stdout, stderr, preexec_fn, close_fds, shell, cwd, env, universal_newlines, startupinfo, creationflags, restore_signals, start_new_session, pass_fds, user, group, extra_groups, encoding, errors, text, umask, pipesize, process_group) 1022 if self.text_mode: 1023 self.stderr = io.TextIOWrapper(self.stderr, 1024 encoding=encoding, errors=errors) -> 1026 self._execute_child(args, executable, preexec_fn, close_fds, 1027 pass_fds, cwd, env, 1028 startupinfo, creationflags, shell, 1029 p2cread, p2cwrite, 1030 c2pread, c2pwrite, 1031 errread, errwrite, 1032 restore_signals, 1033 gid, gids, uid, umask, 1034 start_new_session, process_group) 1035 except: 1036 # Cleanup if the child failed starting. 1037 for f in filter(None, (self.stdin, self.stdout, self.stderr)): File /opt/conda/lib/python3.12/subprocess.py:1955, in Popen._execute_child(self, args, executable, preexec_fn, close_fds, pass_fds, cwd, env, startupinfo, creationflags, shell, p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite, restore_signals, gid, gids, uid, umask, start_new_session, process_group) 1953 err_msg = os.strerror(errno_num) 1954 if err_filename is not None: -> 1955 raise child_exception_type(errno_num, err_msg, err_filename) 1956 else: 1957 raise child_exception_type(errno_num, err_msg) FileNotFoundError: [Errno 2] No such file or directory: 'tidy'
如果Popen找不到tidy,可能需要提供这个可执行文件的完整路径。 在实际工作中,很可能不会打印结果,而是从中提取一些有用的信息, 这将在接下来的几小节中演示。
XHTML和旧式HTML的主要区别在于,XHTML非常严格,
要求显式地结束所有的元素(至少就当前的目标而言如此)。
因此,在HTML中,可通过(使用标签<p>
)开始另一个段落来结束当前段落,
但在XHTML中,必须先(使用标签</p>
)显式地结束当前段落。
这让XHTML 解析起来容易得多,因为能清楚地知道何时进入或离开各种元素。
XHTML的另一个优点是, 它是一种XML方言,可使用各种出色的工具(如XPath)来处理,
但本章不会利用这一点。
要对Tidy生成的格式良好的XHTML进行解析,
一种非常简单的方式是使用标准库模块html.parser
中的HTMLParser类。
使用HTMLParser意味着继承它,并重写各种事件处理方法,如 handle_starttag
和 handle_data
。
下表概述了相关的方法以及解析器在什么时候自动调用它们。以下是 HTMLParser 中的回调方法:
回调方法 | 何时被调用 |
---|---|
handle_starttag(tag, attrs) |
遇到开始标签时调用。attrs 是一个由形如(name, value)的元组组成的序列 |
handle_startendtag(tag, attrs) |
遇到空标签时调用。默认分别处理开始标签和结束标签 |
handle_endtag(tag) |
遇到结束标签时调用 |
handle_data(data) |
遇到文本数据时调用 |
handle_charref(ref) |
遇到形如 &#ref ;的字符引用时调用 |
handle_entityref(name) |
遇到形如 &name ;的实体引用时调用 |
handle_comment(data) |
遇到注释时;只对注释内容调用 |
handle_decl(decl) |
遇到形如<!...> 的声明时调用 |
handle_pi(data) |
用于处理指令 |
unknown_decl(data) |
遇到未知声明时调用 |
就网页抓取而言,通常无需实现所有的解析器回调方法(事件处理程序), 也可能无需创建整个文档的抽象表示(如文档树)就能找到所需的内容。 只需跟踪找到目标内容所需的信息就可以了。
from urllib.request import urlopen
from html.parser import HTMLParser
def isjob(url):
try:
a, b, c, d = url.split('/')
except ValueError:
return False
return a == d == '' and b == 'jobs' and c.isdigit()
class Scraper(HTMLParser):
in_link = False
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
url = attrs.get('href', '')
if tag == 'a' and isjob(url):
self.url = url
self.in_link = True
self.chunks = []
def handle_data(self, data):
if self.in_link:
self.chunks.append(data)
def handle_endtag(self, tag):
if tag == 'a' and self.in_link:
print('{} ({})'.format(''.join(self.chunks), self.url))
self.in_link = False
text = urlopen('http://python.org/jobs').read().decode()
parser = Scraper()
parser.feed(text)
parser.close()
有几点需要注意。首先,这里没有使用Tidy,因为这个网页的HTML格式足够良好。
如果运气好,可能发现并不需要使用Tidy。
另外,使用了一个布尔状态变量(属性)来跟踪自己是 否位于相关的链接中。
在事件处理程序中,检查并更新这个属性。
其次,handle_starttag
的参 数是一个由形如(key, value)的元组组成的列表,
因此使用dict
将它们转换为字典,以便管理。
方法 handle_data
(和属性 chunks
)可能需要稍做说明。
它使用的技术在基于事件的结构化标记(如HTML和XML)解析中很常见:
不是假定通过调用handle_data
一次就能获得所需的所有 文本,
而是假定这些文本分成多个块,需要多次调用handle_data
才能获得。
导致这种情况的原因有多个——缓冲、字符实体、忽略的标记等,
因此需要确保获取所有的文本。
接下来,为了(在方法handle_endtag
中)输出结果,
将所有的文本块合并在一起。
为运行这个解析器,调用其方法feed将并text作为参数,
然后调用其方法close。
在有些情况下,这样的解决方案比使用正则表达式更健壮——应对输入数据变化的能力更强。 然而,可能持反对意见,理由是与使用正则表达式相比,这种解决方案的代码更繁琐, 还 可能不那么清晰易懂。 面对更复杂的提取任务时,支持这种解决方案的论据可能更有说服力, 但即便如此,还是让人依稀觉得一定有更好的办法。 如果不介意多安装一个模块,确实有更佳的办法,下面就来介绍。
$ pip install beautifulsoup4
可能想使用pip进行搜索,看看是否有更新的版本。安装Beautiful Soup, 编写从Python Job Board提取Python职位的程序非常容易,且代码很容易理解,如下面代码所示。 这个程序不检查网页的内容,而是在文档结构中导航。
from urllib.request import urlopen
from bs4 import BeautifulSoup
text = urlopen('http://python.org/jobs').read()
soup = BeautifulSoup(text, 'html.parser')
jobs = set()
for job in soup.body.section('h2'):
jobs.add('{} ({})'.format(job.a.string, job.a['href']))
print('\n'.join(sorted(jobs, key=str.lower)))
使用要从中抓取文本的HTML代码实例化BeautifulSoup类,然后用各种机制来提取解析树
的不同部分。例如,使用soup.body
来获取文档体,再访问其中的第一个section。
使用参数'h2' 调用返回的对象,这与使用其方法find_all
等效——返回其中的所有h2元素。
每个h2元素都表示 一个职位,而感兴趣的是它包含的第一个链接job.a
。
属性string是链接的文本内容,而a['href']
为属性href
。肯定注意到了,
在上面代码中,使用了set和sorted(通过将参数key设置 为一个函数以忽略大小写)。
这些与Beautiful Soup毫无关系,旨在消除重复的职位并按字母顺序 打印它们,
从而让这个程序更有用。
如果要抓取(本章后面将讨论的)RSS feed,可使用另一个与Beautiful Soup相关的工具,
名为 ScrapeNFeed
(http://crummy.com/software/ScrapeNFeed )。