
2.3 把它们拼在一起:抓取维基百科(Wikipedia)网站
在本章中,我们已经讨论了很多强大的工具。最后,让我们假设最后一个关于如何创建网络图的场景,如图2.11所示。
场景 我们想要通过维基百科创建某个主题的网络图。也就是说,我们希望能够输入一个术语(例如:并行计算),并且找到该页面直接关联的所有维基百科页面(即该页面链接的页面,或者链接到该页面的页面)。结果将是我们这个主题所有页面的网络图。

图2.11 网络图是一系列由边连接起来的节点,通常用来表示对象之间的关系,比如人与人之间的友谊、系统之间的通信、城市之间的道路等。
让我们先考虑一下眼前的问题,并草拟一个解决方案。维基百科有一个很好的API,可以用来获取维基百科页面的数据,因此我们将使用这个API。我们知道要创建一个网络图,需要先从某个页面开始,所以要用这个页面作为网络的起点。我们希望获取该页面的所有入站链接和出站链接,这些链接将是图中的其他节点。然后,对于其他每个节点,我们希望得到与它们相关的节点。
我们可以把它进一步分解成以下几个待办事项:
1. 编写一个函数来获取维基百科页面的入站链接和出站链接。
2. 获取初始页面的入站链接和出站链接。
3. 把这些页面整理成一个长的列表。
4. 获取所有这些页面的入站链接和出站链接。
5. 对这些页面进行并行抓取,以加快速度。
6. 将所有链接表示为页面之间的边。
7. 奖励:使用一个图形库(如networkx)来展示图形。
让我们先编写一个函数,根据一个维基百科页面的标题来获取其入站链接和出站链接。我们将先从urllib模块中导入JSON模块和requests类。你应该还记得之前提到的urllib模块吧,它用来获取互联网上的数据,这也正是我们希望对维基百科页面所做的事情。JSON模块是一个解析JSON格式数据的模块。它可以将JSON数据读取为原生的Python类型。我们将使用它把维基百科上的数据转换为一种更易于管理的格式。
接下来,我们将创建一个函数,将维基百科页面的链接转换为页面的标题。维基百科会自动将这些链接打包为一个JSON对象,而我们只需要其中的标题字符串。
最后,我们需要创建一个实际获取维基百科信息的函数。我们的函数get_wiki_links需要接受一个页面标题,并将其转换为一个入站链接和出站链接的dict(字典)对象。这个dict对象让我们稍后可以轻松地访问这些链接。
在这个函数中要做的第一件事是创建查询的URL。如果你想知道从哪里获得URL,维基百科提供了一个文档丰富的在线API;我将在这里解释与我们有关的部分。访问/w/api.php是告诉维基百科,我们希望使用它的API,而不是请求一个标准的Web页面。action=query是告诉维基百科我们将执行一个查询操作。查询是维基百科提供的众多操作之一。它用来获取关于页面的元数据,比如哪些页面链接到了指定页面,以及指定页面链接了其他哪些页面。
API参数prop=links|linkshere是告诉维基百科我们感兴趣的属性是页面链接,以及哪些页面链接到了该页面。参数pllimt和lhlimit告诉API我们希望最多获得500个结果。这是我们在不注册为一个机器人的情况下,所能得到的最大结果数量。title参数用来设置所需的页面标题,而format参数定义了返回的数据应该如何格式化。为了方便起见,我们将选择JSON格式。
接下来,我们可以把URL集合传递给request.urlopen方法,通过一个get请求来打开URL。维基百科会将请求传递给它的API,并将请求的信息返回给我们。我们可以使用read方法将这些信息读入内存,在这里我们也的确是这样做的。因为我们要求维基百科以JSON格式返回信息,所以可以用json.reads方法来读取JSON字符串,将JSON字符串转换为Python对象。最后产生的对象j是一个dict对象,表示维基百科返回的是JSON对象。
现在我们可以借助这些对象,通过4次调用来获得链接,比如page['que-ry']['pages'][0]["links"]和page['query']['pages'][0]["link-shere"]。前一个对象包含了当前页面链接到的页面,后一个对象包含了链接到当前页面的页面。维基百科的API定义了这个结构,因此我们知道如何找到所需的数据。如前所述,这些对象(links和linkshere)不是页面的标题,而是一些JSON对象,标题是其中的一个元素。为了获得标题,我们需要使用link_to_title函数。因为有多个链接,并且这些链接在一个列表中,所以我们需要使用map函数将所有对象转换为它们的标题。
最后,我们将以一个dict对象的形式返回这些对象。所有的代码如清单2.7所示(下面“*********.org”所代表的具体网址可通过http://www.broadview.com.cn/40368进行下载)。
清单2.7 根据页面标题获取某个维基百科页面网络的函数

此时,我们已经处理了待办事项清单上的第一项,并且使我们在处理第二项和第三项时能处于有利地位。我们目前可以编写一个小函数,并且创建脚本的“仅执行时运行的代码”部分。现在就开始吧。
接下来,这里是一个简单的函数,可以将页面的入站链接和出站链接合并成一个大的列表:

这是我们所编写代码的一部分,只有用Python 3来调用这个脚本时它才会运行:

if__name__=="__main__"告诉Python,只有作为脚本直接被调用时,才会使用这段代码。后面几行表示使用我们的函数,获取“并行计算”(Parallel_computing)维基百科页面上的所有链接。最后一行将整个网络存储在一个变量中。
接下来,让我们使用这个列表及刚刚编写的函数来获取“并行计算”(Parallel_computing)页面网络中的所有维基百科页面。我们希望并行执行,从而加快执行速度,因此我们需要扩展之前的“仅执行时运行的代码”的部分。我们将添加几行代码,所以它看起来如下所示:

我们再次调用了Pool类,以便在并行编程中使用一些处理器。然后,我们通过这些处理器来获取每个链接到根页面(Parallel_computing)或者根页面所链接到的页面的信息。相较于一个一个地抓取页面的时间,如果我们有4个处理器,那么可以在四分之一的时间内完成这项任务。
现在,我们希望将每个页面对象表示为页面之间的边。这看起来会是什么样子呢?一个很好的表示方法是使用一个tuple(元组)对象,第一个位置的对象表示链接的页面,第二个位置的对象表示被链接到的页面。如果Parallel_computing页面链接到“Python”页面,那么就需要这样的一个tuple对象:("Parallel_com-puting","Python")。
为了创建这些对象,我们需要另外一个函数。这个函数将把每个页面的dict对象转换成一个表示这些边的元组对象的列表。

这个函数会循环遍历页面网络中的每个页面,创建一个包含out-links中所有页面tuple对象(page,out-link)的列表,以及一个包含in-links中所有页面tuple对象(in-link,page)的列表。然后将这两个列表相加并返回。
我们还需要更新代码的脚本部分。这部分代码现在看起来是这样的:


我们已经添加了一行代码,将page_to_edges函数应用于前面函数所收集的所有页面。因为我们现在仍然拥有这些处理器,所以再次使用它们来加速完成这项任务。
我们要做的最后一件事,是将这个表示边的列表平铺成一个大列表。最好的方法是使用Python中itertools库的chain函数。chain函数可以接受多个可迭代对象,并将它们链接在一起,这样就可以一个接一个地访问它们。例如,它允许我们把[[1,2,3],[1,2],[1,2,3]]当作[1,2,3,1,2,1,2,3]]来对待。
我们将对edges(边)对象使用chain函数。现在,我们已经借助处理器完成了并行化,因此现在要简化这部分代码,不再使用处理器。

chain函数在默认情况下是惰性的,所以如果我们想要将它打印到屏幕上,就需要把它包装到一个列表(list)调用中,就像map函数一样。如果你决定将它打印到屏幕上,就将看到1000000个字符串-字符串的tuple对象(网络中有1000个页面,每个页面有1000个tuple对象)。
注意 我们只编写了大约50行代码,并且是一段一段编写的。当我们像这样编码时,有时可能会有一些小的差错,从而导致代码运行失败。如果你在运行代码时遇到困难,请记住你可以在网上找到本书的源代码(请参见链接6)。
2.3.1 可视化我们的图
将图可视化的最佳方法是将其从Python中取出,导入Gephi中。Gephi是一款优秀的网络和图可视化工具,在社会科学领域享有盛誉。Gephi可以处理多种格式的数据,但是其更倾向于处理自定义的.gefx格式。我们将使用一个名为networkx的Python库,将图导出为这种格式。整个过程是这样的:

我们在这里创建了一个有向图(nx.DiGraph)对象,并通过遍历我们所链接的边,向图对象中添加了边。图对象有一个方法add_edge,它允许我们通过依次声明图的边来构造一个图。完成这些之后,剩下要做的就是将图导出为Gephi格式的.gefx。networkx库提供了一个方便的函数write_gefx。我们将用它来调用图对象,并提供一个文件路径名作为参数。这个图会以.gefx格式保存在指定的路径下。在我的机器上,输出文件略小于36MB。
注意 Gephi是一款优秀的图可视化软件;然而,本书并不是一本介绍图可视化的书。如果你认为可视化Web抓取无法令你满意,或者如果你不习惯使用Gephi,那么可以直接忽略这部分内容。在本书中,我们不会再用到Gephi了。
从这里,我们可以启动Gephi,导入.gefx文件,然后查看我们的图。如果你没有安装Gephi,可以通过链接7下载它。Gephi是一款自由软件,以开放源码许可证的形式发布,可以运行在Windows、macOS和Linux上。
当你打开Gephi的时候,可能需要对图进行一些设置,以便让它显示得更漂亮。我打算把图可视化这部分留给“你和你的创造力”,因为我远远不是这方面的专家。
如果你没有耐心来学习如何可视化超过100000个节点的图,请更改查询中的设置,以便获取更少的页面。我将把这个任务留给你,需要你回顾之前的代码并找出实现的方法。(提示:在我们对维基百科API的请求中。)
当我对每个页面仅仅请求50个相邻页面时,最终会得到一个大约1300个节点的网络。在Gephi中,这个网络在默认情况下类似于图2.12所示。

图2.12 围绕维基百科“并行计算”页面的关系网络
2.3.2 回到map函数
在我们结束本章内容之前,有必要看看自己所做的事情,与之前介绍map函数的图是如何匹配的。回到map数据转换这张非常有用的图上,因为它允许我们以一种简单的方式,将一个复杂的任务(Web抓取和创建一个实体网络)置于上下文环境中。
首先,让我们从整个流程图开始(如图2.13所示)。在左边,我们从一个种子文档开始。我们将get_wiki_links函数应用在该文档上,从而获得网络中的所有页面,包括入站链接和出站链接的页面。然后,我们将get_wiki_links函数通过map函数应用到所有这些页面上,从而返回一个更大的扩展网络,即种子页面链接到或者被链接的页面,以及这些页面所链接到和被链接的页面。随后,我们将所有这些链接转换成边。这将数据从一个隐式的数据结构转换为显式定义的图。最后,我们用所有这些边来构造了一个图。
在这个过程中,我们使用了两个map语句:一个用来将初始网络变成扩展网络,另一个用于将扩展网络变成边。在第一个实例中,如图2.13所示,我们获取了所有从种子页面中抓取的链接。我们抓取了这些链接的页面,然后返回了每个链接的网络。结果是,之前我们有一个页面的列表(或者一个含有页面标题、入站链接以及出站链接的dict对象,如果你还记得数据是什么样子的话),现在我们有了一个页面列表的列表(或者说,是这些“页面”dict对象的列表)。虽然这中间发生了很多事情,包括我们会“ping”维基百科的API,维基百科的API会获取页面并返回结果,我们将结果转换为JSON对象,通过JSON对象找到我们想要的值,最后将它们存储在一个dict对象中并返回这个dict对象——我们可以将所有这些都看作是从一个对象到另一个对象的数据转换。

图2.13 我们将通过4个步骤将一个种子页面转换为一个页面网络。
接下来,我们要完成第三步,将在第二步中获得的网络转换为可用来定义有向图的边的列表。为此,我们编写了一个path_to_edges函数。我们所做的事情并不复杂,即合并两个字符串列表并将它们转换成一个tuple列表。不过,将path_to_edges函数抽象出来,让我们可以在更高的层面上可视化整个转换过程。这种高层次的理解直接对应着整个过程,并且强调了正在发生的事情,即我们正在将一个链接网络转换为图的边。
回顾一下我们刚刚编写的抓取维基百科页面、创建链接网络的程序,可以看到map函数对于很多任务是很合适的。实际上,任何时候将某种类型的序列转换成另一种类型的序列时,都可以表示为一种映射。我喜欢将这些情况称为N-N的转换,因为我们正在将N个数据元素,转换为另外一些数量相同但格式不同的数据元素。
在上一个例子中,我们遇到了两种N-N的情况。我们首先把N个链接变成N个链接网络,然后把N个链接网络变成N条边。在这两种情况中,我们都使用了一个map函数,就像刚才图2.13中所画的那样。
我们还使用了并行编程来更快地完成任务。这些任务很适合并行编程,因为它们都是一些耗时的、重复的任务,可以用一些独立的指令简洁地表示。为了完成这些任务,我们使用了一个并行的map函数,它不仅能够实现我们期望的并行化,而且可以让我们使用与非并行map函数类似的语法。总之,并行化这个问题所需的工作量总共只有4行代码,包括导入一个库,使用Pool()来争用处理器,修改我们的map语句,以及改为使用Pool的map方法。