数据也“洁癖”

数据也“洁癖”

{%}

作者/Megan Squire

依隆大学计算科学专业教授,主要教授数据库系统、Web开发、数据挖掘和数据科学课程。有二十年的数据收集与清洗经验。她还是FLOSSmole研究项目的领导者,致力于收集与分析数据,以便研究免费软件、自由软件和开源软件的开发。

在所有的厨具当中,滤器是最常用的工具之一,它的作用就是在烹饪的过程中将固体从液体中分离出来。真实世界中的数据也有很多问题。在使用数据之前,通常需要对数据进行一定的预处理,为来自网络的数据构建滤器,利用不同类型的程序找到并保存想要的数据,同时去除不需要的内容。

我们将要完成以下几项内容的学习。

  • 理解两种不同的HTML页面结构,第一种是行集合,我们可以根据样式匹配其中的内容;第二种是带有节点的树形结构,我们可以利用这些节点来识别并找出想要的数据。

  • 尝试使用三种方法来解析页面,一种是使用逐行方法(基于正则表达式的HTML分析技术),另外两种则是使用树形结构方法(Python的BeautifulSoup库和一个叫作Scraper的基于Chrome浏览器的工具)。

  • 根据实际的数据情况来实现这三种技术方案:从已经发布的网络论坛消息中去除日期和时间。

理解HTML页面结构

网页就是一个纯文本文件,其中包含了一些特殊的标记元素(有时候称为HTML标签),用来告知浏览器应该如何向用户呈现要显示的内容。例如,如果我们想强调某个单词,那么只需像下面的代码那样把单词用<em>标签围住即可:

It is <em>very important</em> that you follow these instructions.

所有的网页都具备这样的特性:它们本身都是由文本组成的,并且这些文本中会包含许多不同的标签。如果想从网页中提取数据,大抵可以采用两类心智模型。这两类模型分别有着各自的侧重点。下面我们先对这两类结构化模型做一番介绍,然后使用三种不同的工具来进行数据提取演示。

行分隔模型

理解网页的最简单方式就是专注于这一事实:网页上有许多不同的HTML元素/标签,用来在网络上显示页面中的内容。如果我们想从这个简单的模型中抽取自己比较感兴趣的数据,就必须把网页中的文字和嵌套在其中的HTML元素本身当作分隔符。就拿前面的示例来说,我们可以提取标签<em>里面的数据,也可以是标签<em>之前或是标签</em>之后的数据。

在这个模型中,我们需要发挥一下想象力,把网页想象成由超大的无结构文本和一些根据需求组织起来的HTML标签(或别的文本特性,如反复出现的词)共同组成的一个集合,其中的结构化标签可以帮助我们把内容分隔成不同的部分。有了这些分隔标记,从页面提取数据就不会成为难事。

请看下面的HTML页面代码片段,它们取自Django IRC频道的聊天日志。现在让我们想想怎么把HTML元素当成分隔符,提取我们需要的数据:

<div id="content">
<h2>Sep 13, 2014</h2>

<a href="/2014/sep/14/">← next day</a> Sep 13, 2014  <a
  href="/2014/sep/12/">previous day →</a>

<ul id="ll">
<li class="le" rel="petisnnake"><a href="#1574618"
  name="1574618">#</a> <span style="color:#b78a0f;8"
  class="username" rel="petisnnake">&lt;petisnnake&gt;</span> i
  didnt know that </li>
  ...
  </ul>
  ...
  </div>

在示例文本中,我们可以使用<h2></h2>标签作为分隔符来提取聊天日志中的日期。还可以使用<li></li>作为分隔符并找到其中的rel=""来提取聊天人的名字。最后可以提取</span></li>之间由用户向聊天频道发出的消息。

这些聊天日志可以从Django IRC日志网站获取,地址为http://django-irc-logs.com。这个网站同时还支持使用关键字搜索日志内容的接口。为了保持代码简洁,部分内容采用省略号(…)代替。

从上面杂乱无章的文本中,我们可以使用分隔符的概念来提取三段数据(日志时间、用户和消息)。

树形结构模型

另一种理解方式是把文本页面想象成一个由HTML元素/标签组成的树形结构,其中每个元素/标签都与页面上的其他一些标签相关。每个标签都是一个节点,整棵树是由页面中所有节点组成的。在HTML中,如果一个标签出现在另一个标签的内容中,那么内部标签就被称为子标签,外部标签被称为父标签。在之前的IRC聊天日志的例子中,HTML片段展现的树形图如下:

{%}

如果把HTML文本想象成树形结构,那么我们就可以使用编程语言来构建这棵树。这样,我们就可以根据元素的名字或是在元素列表中的位置,提取需要的文本数据。例如:

  • 我们想要根据标签的名字提取数据(节点<h2>中的文本);

  • 我们想要抓取某种标签类型下的所有节点(<div>内部的<ul>页签下的所有<li>节点);

  • 我们想要一个指定元素中的所有属性(<li>元素中的rel属性)。

接下来的部分,我们将把心智模型——行分隔模型和树形结构——应用到实际的案例当中,采用三种不同的方法来提取和清洗HTML页面中的数据。

方法一:Python和正则表达式

我们使用简单的方法提取HTML页面中的数据。这个方法是基于识别文件中的分隔符的概念,并同时配合正则表达式的使用,最终提取我们需要的数据。

在开始实践之前还有一件事我们得铭记于心,即正则表达式这种实现方案虽然非常容易理解,但也有很大的局限性,具体取决于项目的的细节。

第一步:查找并保存实验用的Web文件

在这个例子中,我们将要抓取前面Django项目中提到的IRC聊天记录。这些文件都是公开的,并且结构也比较常规,所以比较适合我们这个项目使用。进入Django IRC日志归档网站http://django-irc-logs.com/,选择一个你认为比较有意义的日期。进入所选日期对应的目标页面后,把文件保存到你的本地工作目录。下载完成后你会得到一个以.html结尾的文件。

第二步:观察文件内容并判定有价值的数据

.html文件是纯文本文件,可以通过文本编辑器查看相关内容,所以这一步的目标很容易实现。只需要用文本编辑器打开HTML文件,然后仔细查看其中的内容即可。但是有哪些内容可以提取呢?

在观察过程中,我确实发现了几项可以提取的内容。从每一条聊天记录的备注内容中可以看出,里面有行号、用户名、备注这些信息。接下来我们得为抽取这三项内容做一些准备了。

下面截图显示的是在文本编辑器中打开的HTML文件。当时我打开了折行功能,因为有些行的内容实在是太长了(在Text Wrangler中可以通过View | Text Display | Soft Wrap Text打开这个功能)。在29行上下我们可以看到聊天记录的开始部分,每一行都包括我们想要的三项内容。

{%}

接下来的工作就是从每一行中找出看起来具有相同特点的内容,这样就可以从聊天记录里抓取我们所需的那三项内容了。从文本内容中我们可以发现,只要遵循下面的规则,就可以在改动最小的情况下准确地抽取每一项数据。

  • 我们需要抽取的三项数据都可以在标签<li>内找到,并且这个标签全部处于标签<ul id="ll">内部。每一个<li>代表一条聊天消息记录。

  • 在消息中,行号信息可以从两个位置找出:一处是字符串<a href="#,另一处是name属性后面的双引号之间。在示例中,第一个出现的行号是1574618

  • 属性username可以在三个地方找到,第一处是标签li class="le"rel属性值,第二处是标签span中的rel属性,最后一处则是处于&lt;&gt;之间。在示例中,第一个username的值是petisnnake

  • 消息信息处于标签</span></li>之间。在例子中,第一条消息是i didnt know that

有了数据的查找规则,就开始写程序吧。

第三步:编写Python程序把数据保存到CSV文件中

我们编写了下面的代码,用来打开一个指定的IRC日志文件,文件的格式与我们之前描述的一样。利用这份代码我们可以分别解析出所需的三项数据并把它们输出到CSV文件:

import re
import io

row = []

infile  = io.open('django13-sept-2014.html', 'r', encoding='utf8')
outfile = io.open('django13-sept-2014.csv', 'a+', encoding='utf8')
for line in infile:
    pattern = re.compile(ur'<li class=\"le\" rel=\"(.+?)\"><a
        href=\"#(.+?)\" name=\"(.+?)<\/span> (.+?)</li>', re.UNICODE)
    if pattern.search(line):
        username = pattern.search(line).group(1)
        linenum = pattern.search(line).group(2)
        message = pattern.search(line).group(4)
        row.append(linenum)
        row.append(username)
        row.append(message)
        outfile.write(', '.join(row))
        outfile.write(u'\n')
        row = []
infile.close()

这段代码最复杂的地方就是pattern那一行。文件中的每一行文本都会与该行代码构建出来的样式匹配进行比较。

注意,网站的开发者可以随时改变HTML结构,所以我们是冒着风险干这件事的,因为说不定哪天我们精心编写的样式就无法工作了。

每组匹配的数据样式都是.+?。这样的数据一共有五组,其中三组是我们有兴趣的数据(用户名、行号和消息),另外两组对我们没有什么用处,可以直接丢弃。页面中的其余内容也都可以丢弃,因为它们与我们设计的样式无法匹配。我们的程序就像一个筛子一样,当中恰好留了三个功能漏点。有用的数据都会从这些漏点中过滤下来,留下的都是没有用的数据。

第四步:查看文件并确认清洗结果

用文本编辑器打开新创建的CSV文件,这时我们看到的前几行内容应该与下面一样:

1574618, petisnnake, i didnt know that
1574619, dshap, FunkyBob: ahh, hmm, i wonder if there's a way to do
it in my QuerySet subclass so i'm not creating a new manager subclass
*only* for get_queryset to do the intiial filtering
1574620, petisnnake, haven used Django since 1.5

结果看起来还不赖。不过有件事你可能已经注意到了,第三列文本没有使用封闭字符。由于我们把逗号作为分隔符号使用,这下我们得面对新的问题了。第三列文本中的逗号该怎么处理呢?如果这个问题让你苦恼的话,你可以选择用引号把第三列内容封闭起来,或者是改用制表符来作为分隔符号。要想改变分隔符号,我们需要对第一个outfile.write()语句做一些修改,把当中连接用的逗号全部替换成\t(制表符)。在处理中,你还可以使用函数ltrim()来清洗消息中包含的多余空白字符。

使用正则表达式解析HTML的局限性

正则表达式这个方案乍看起来挺简单的,但它是有局限性的。首先,对于数据清洗工作者来说,设计正则表达式样式可能就是一件苦差事。你得花费大量的时间和精力来调试样式、编写冗长的说明文档。所以,为了辅助正则表达式的开发,我极力推荐使用像http://Pythex.org这样的正则表达式测试工具,当然,你也可以用搜索引擎找一款更适合你自己使用的测试工具。如果你使用的语言是Python的话,一定要指明你想要的是Python正则表达式测试工具。

接下来,你应该事先搞清楚一件事,正则表达式是完全依赖网页结构的。因此,如果你打算从网站上收集数据,那么你今天编写的正则表达式很有可能明天就无法工作了。表达式样式只有在页面布局没有发生变化的时候才能正常工作。或许刚刚在两个标签之间加入的一个空格字符就能让整个正则表达式失效,但分析起来却是困难重重。还有一点需要谨记的是,你无法干预网站的变化,因为要收集的大部分数据不是源于你自己的网站!

最后要讲的是,很多情况下无法使用正则表达式来精确地匹配HTML结构的。正则表达式虽然强大,但它并非完美得无懈可击。对于这个问题,我推荐你参考著名的Stack Overflow上一个超过4000次点赞的答案:http://stackoverflow.com/questions/1732348/。在这个答案中,作者幽默地表达了许多程序员的失望之情,他们曾经一次次尝试解释为什么正则表达式不是解析无规则的频繁变化的HTML页面的最佳方案。

方法二:Python和BeautifulSoup

因为正则表达式有诸多局限性,我们必须得用更多的工具来进行补充。在这里,我们将使用Python的BeautifulSoup库来解析树形结构的HTML页面。

第一步:找到并保存实验用的文件

在这一步中,我们还使用方法一中的那个文件:来自Django IRC频道的文件。我们还是提取相同的三个内容。这样做很容易比较出这两种方法的差异。

第二步:安装BeautifulSoup

BeautifulSoup目前已经发展到第四版。这个版本同时兼容Python 2.7和Python 3。

如果你使用的是Enthought Canopy Python环境,在Canopy Terminal里运行pip install beautifulsoup4就可以安装BeautifulSoup。

第三步:编写抽取数据用的Python程序

我们需要的三项内容可以从li标签集合中找到,更准确地说是那些带有class="le"样式的标签。在这个文件中虽然再没有别的li标签了,但为了谨慎起见我们在操作时还是应该尽可能地指明具体信息。下面的列表描述了在解析树形结构时我们需要获取的内容以及它们的所在位置。

  • 我们可以从li标签中的rel属性提取username值。

  • 我们可以从标签中的name属性提取linenum值。这个标签指的是li内部的第一个a标签。

请记住,数组索引是从零开始的,所以我们需要使用0获取第一个项目的内容。

在BeautifulSoup中,标签的内容就是被解析的树形结构的标签的内部项目,在有些包中被称为子项。

  • 我们可以把message当作li标签的第四项内容(也就是数组中的内容[3])来处理。还有一点需要注意的是,每条消息的前面都有一个打头的空白字符,我们需要在保存数据之前把它处理掉。

下面是解析树形结构的Python代码:

from bs4 import BeautifulSoup
import io

infile  = io.open('django13-sept-2014.html', 'r', encoding='utf8')
outfile = io.open('django13-sept-2014.csv', 'a+', encoding='utf8')
soup = BeautifulSoup(infile)

row = []
allLines = soup.findAll("li","le")
for line in allLines:
    username = line['rel']
    linenum = line.contents[0]['name']
    message = line.contents[3].lstrip()
    row.append(linenum)
    row.append(username)
    row.append(message)
    outfile.write(', '.join(row))
    outfile.write(u'\n')
    row = []
infile.close()

第四步:查看文件并确认清洗结果

用文本编辑器打开新创建的CSV文件,从前几行数据中我们可以发现,这基本上与第一种方法产生的结果一样:

1574618, petisnnake, i didnt know that
1574619, dshap, FunkyBob: ahh, hmm, i wonder if there's a way to do
it in my QuerySet subclass so i'm not creating a new manager subclass
*only* for get_queryset to do the intiial filtering
1574620, petisnnake, haven used Django since 1.5

与正则表达式方案类似,如果你对最后一列中的逗号分隔符不放心的话,可以用双引号把其中的文本封闭起来,或者是改用制表符作为字段分隔符。

方法三:Chrome Scraper

如果你不想用程序解析数据的话,还有一些基于浏览器的工具可以让你从树形结构中抽取数据。我认为最简单省事的方案就是使用Chrome应用扩展中一个叫作Scraper的工具,它的开发者名为mnmldave(真名:Dave Heaton)。

第一步:安装Chrome扩展Scraper

如果你还没有安装Chrome浏览器的话,那就先下载一个。然后再确认一下你是不是安装了正确版本的Scraper,因为名字类似的扩展插件不止一个。我推荐直接从开发者本人的GitHub站点获取这个扩展插件,地址为http://mnmldave.github.io/scraper/。通过这种途径获取的程序肯定没有问题,这样你就不用在Chrome商店中到处找了。从http://mmldave.github.io/scraper点击链接安装完扩展插件后,重新启动浏览器。

第二步:从网站上收集数据

现在可以用你的浏览器访问我们之前两个数据提取实验所使用的URL,随便访问一个Django IRC日志。我使用的是2014年9月13日的数据,所以我的访问地址是http://django-irc-logs.com/2014/sep/13/

我浏览页面时的样子如下:

{%}

IRC日志中已经包含了我们需要提取的三项内容:

  • 行号信息(从之前的两个实验中我们可以得知,链接内容里符号#之后的文本就是行号)

  • 用户名(符号<>之间的文本)

  • 消息信息

Scraper可以依次把那些要提取的数据进行高亮显示,并把结果输出到Google Spreadsheet中去,之后我们可以把这些输出结果重新整理到一张工作表里,并以CSV格式(或是别的格式)输出。下面是具体的操作步骤。

(1) 使用鼠标高亮选中你想要抓取的数据。

(2) 点击鼠标右键并从菜单中选择Scrape similar...。从下面的截图中可以看出,我选择用户名petisnnake作为抓取对象。

{%}

(3) 选择Scrape similar之后,工具显示出一个新的窗口,其中包括了页面中其他相似的项目。下面的截图显示了Scraper找出的完整用户名列表。

{%}

图:Scraper根据示例中的用户名找出了所有类似的项目。

(4) 在窗口的底部有一个名为Export to Google Docs...的按钮。这里需要注意的是,在不同的设置条件下,你可能需要点击同意之后才可以让Scraper访问Google Docs。

第三步:清洗数据

当我们把所有需要的数据元素都从页面中取出来并放到Google Docs后,就可以将它们组织到一个文件里并做最后的清洗工作了。下面是行号信息抽取之后的样子,而此时我们还没有对它们进行清洗。

{%}

对于A列中的内容我们并不关心,而且打头的符号#也不是我们想要的。用户名和每行的消息数据看起来也差不多——其中大部分数据都是有效的,但我们还是得先移除不需要的符号,然后把所有的内容都集中到一个独立的Google Spreadsheet中去。

使用删除符号#<>之后,把所有的行粘贴到一个新的工作表中,可以得到像下面一样干净的数据集:

{%}

Scraper非常适合从网页中抓取少量数据。它有一个便捷的Google Spreadsheets接口,如果你不想编写程序的话,这不失为一个快捷的解决方案。在接下来的一节中,我们将迎接一个更大的项目。它非常复杂,可能把这一章中的多个概念组合到一起才能形成一个完整的解决方案。

示例项目:从电子邮件和论坛中抽取数据

Django IRC日志项目非常简单。它的目的就是向你展示三种常见的HTML页面数据抽取技术之间的差异。我们需要抽取的数据包括行号、用户名和IRC聊天消息,找到这些数据并不难,几乎也不需要什么额外的清洗操作。而在即将开始的新项目中,概念上和之前的项目还是有相似之处的,但我们得把数据抽取思路扩展到HTML格式之外的两种Web中常见的半结构文本格式:Web中的电子邮件消息和基于网页的论坛消息。

项目背景

我最近做了一项关于社交媒体如何为软件技术提供支持的研究。具体说来,我想知道,是不是应该让某些API和框架的软件开发组织把他们对开发人员的支持转移到Stack Overflow上去,或者他们是不是应该继续使用传统的媒体模式,比如电子邮件和网络论坛。为了完成这项研究,我特意比较了软件开发人员获取API问题答案的时间,其中有的是通过Stack Overflow,有的是通过传统的社交媒体,比如网络论坛和电子邮件组。

我们会下载两种不同类型的原始数据,分别代表了不同的传统社交媒体:网络论坛的HTML文件和Google Groups的电子邮件。我们会编写Python代码来提取这两个论坛消息里所包含的日期和时间。之后再整理出哪些消息是用于回复其他消息的,并对回复的间隔时间做一个简单的汇总统计。

我们暂时不抽取Stack Overflow上的数据样本来回答刚才提出的问题。

这个项目被划分为两个部分。在第一个部分中,我们从Google Groups的一个项目的归档邮件中抽取数据,第二部分从另一个项目的HTML文件抽取数据。

第一部分:清洗来自Google Groups电子邮件的数据

现在还有许多软件公司在使用传统的电子邮件列表,或是同时使用邮件与网络论坛来为他们的产品提供技术支持。Google Groups就是这种服务的一种流行应用。用户可以向组内发送邮件,或是通过网络浏览器阅读与搜索邮件消息。但是,有的公司已经把技术支持搬离了Google Groups(包括Google本身的一些产品),取而代之的是使用Stack Overflow。数据库产品Google BigQuery就是使用Stack Overflow的组织之一。

第一步:收集Google Groups消息

为了研究BigQuery在Google Group上的问题响应时间,我特意为组内所有的回复创建了一份URL清单列表。你可以从https://github.com/megansquire/stackpaper2015/blob/master/BigQuery-GGurls.txt找到完整的列表内容。

有了目标URL列表之后,我们就可以编写Python程序来下载URL中所涉及的所有电子邮件,并把它们保存到磁盘中。在下面的程序里,我把URL清单列表保存在一个名为GGurls.txt的文件里。程序引用了time库,这样就可以在请求Google Groups服务器时使用sleep()函数稍作停歇:

import urllib2
import time

with open('GGurls.txt', 'r') as f:
    urls = []
    for url in f:
        urls.append(url.strip())

currentFileNum = 1
for url in urls:
    print("Downloading: {0} Number: {1}".format(url, currentFileNum))
    time.sleep(2)
    htmlFile = urllib2.urlopen(url)
    urlFile = open("msg%d.txt" %currentFileNum,'wb')
    urlFile.write(htmlFile.read())
    urlFile.close()
    currentFileNum = currentFileNum +1

程序运行之后一共在磁盘上生成了667个文件。

第二步:从Google Groups消息中抽取数据

现在我们已经有了667组电子邮件消息,每组消息都以独立文件的形式存在。接下来的任务就是编写一个程序每次读取一个文件,并用本章中学到的技术从文件中提取我们需要的信息。无论从哪个邮件中我们都可以发现很多头部信息,其中存储了电子邮件信息或是相关的元数据。现在让我们快速地浏览一下三段头部信息,找出需要的原数据:

In-Reply-To: <ab71b72a-ef9b-4484-b0cc-a72ecb2a3b85@r9g2000yqd.
googlegroups.com>
Date: Mon, 30 Apr 2012 10:33:18 -0700
Message-ID: <CA+qSDkQ4JB+Cn7HNjmtLOqqkbJnyBu=Z1Ocs5-dTe5cN9UEPyA@mail.
gmail.com>

所有的消息都有Message-IDDate,但In-Reply-To只会在回复消息中出现。In-Reply-To的值必须是另一条消息的Message-ID

下面的代码演示的是一种基于正则表达式的解决方案,它能够抽取DateMessage-IDIn-Reply-To值,并创建出原始消息与回复消息的列表。然后计算出消息和它们对应的回复消息之间的时间差。

import os
import re
import email.utils
import time
import datetime
import numpy

originals = {}
replies = {}
timelist = []

for filename in os.listdir(os.getcwd()):
    if filename.endswith(".txt"):
        f=open(filename, 'r')
        i=''
        m=''
        d=''
        for line in f:
            irt = re.search('(In\-Reply\-To: <)(.+?)@', line)
            mid = re.search('(Message\-ID: <)(.+?)@', line)
            dt = re.search('(Date: )(.+?)\r', line)
            if irt:
                i= irt.group(2)
            if mid:
                m= mid.group(2)
            if dt:
                d= dt.group(2)
        f.close()
        if i and d:
            replies[i] = d
        if m and d:
            originals[m] = d

for (messageid, origdate) in originals.items():
    try:
        if replies[messageid]:
            replydate = replies[messageid]
            try:
                parseddate = email.utils.parsedate(origdate)
                parsedreply = email.utils.parsedate(replydate)
            except:
                pass
            try:
                # 这个地方有时会生成错误的时间值
                timeddate = time.mktime(parseddate)
                timedreply = time.mktime(parsedreply)
            except:
                pass
            try:
                dtdate = datetime.datetime.fromtimestamp(timeddate)
                dtreply = datetime.datetime.fromtimestamp(timedreply)
            except:
                pass
            try:
                difference = dtreply - dtdate
                totalseconds = difference.total_seconds()
                timeinhours =  (difference.days*86400+difference.seconds)/3600
                # 这个地方应该处理负数时间的
                # 或许用时区处理比较合适,暂时还是算了吧
                if timeinhours > 1:
                    # 打印小时数
                    timelist.append(timeinhours)
            except:
                pass
    except:
        pass

print numpy.mean(timelist)
print numpy.std(timelist)
print numpy.median(timelist)

从代码中可以看出,最开始的for循环会依次提取我们所需的三种信息。(这段程序不会把提取出来的信息存放到一个独立的文件或是磁盘中,如果有需要的话你可以自己实现这个功能。)它创建了两个重要的列表。

  • originals[]是包含原始消息的列表。这些消息都是最初提出的问题。

  • replies[]是包含回复消息的列表。这些消息都是针对最初提出的问题的回复。

第二段for循环处理会对原始消息列表中的每条消息做进一步处理,如果原始消息有对应的回复消息,就尝试找出经过多长时间回复消息才被发出。我们会为回复时间做出一个新的记录列表。

(1) 数据提取代码

这里我们最为关心的代码是数据清洗和数据抽取部分,所以直接阅读下面的代码就可以了。程序会逐行处理电子邮件中的内容,目的是找出关键的三个邮件头部信息:In-Reply-ToMessage-IDDate。代码中使用正则表达式来搜索与组织数据,就像我们在前面的方法一中那样,对头部信息进行了拆分并提取出相应的数据:

for line in f:
    irt = re.search('(In\-Reply\-To: <)(.+?)@', line)
    mid = re.search('(Message\-ID: <)(.+?)@', line)
    dt = re.search('(Date: )(.+?)\r', line)
    if irt:
        i = irt.group(2)
    if mid:
        m = mid.group(2)
    if dt:
        d = dt.group(2)

我们为什么要用正则表达式来代替树形结构的解析方法呢?原因主要有以下两个。

(a) 因为下载下来的电子邮件不是HTML格式,所以我们不能使用父子关系的树形结构来描述文件内容。因此,像BeautifulSoup这样基于树形结构的方案是行不通的。

(b) 因为电子邮件头部信息的结构都是固定的,并且内容也是可以预测的(尤其是我们需要的那三条数据),所以我们可以采用正则表达式来完成这个处理。

(2) 程序输出

程序的输出就是打印出三个数字,表示在该Google Group中以小时为计算单位的情况下,其均方差、标准差和中位差分别是多少。下面是代码在我的环境中运行出来的结果:

178.911877395
876.102630872
18.0

这意味着在BigQuery Google Group上对一条消息的平均响应时间是18小时。现在让我们再研究一下怎么从另一种数据源——网络论坛——中提取类似的数据。你觉得网络论坛中的消息响应会怎么样呢?更快、更慢,还是跟Google Group上一样?

第二部分:清洗来自网络论坛的数据

在这个项目中我们要研究的网络论坛来自一家名为DocuSign的公司。他们也把对开发者的支持转移到Stack Overflow上了,但同时对之前基于网络的开发者论坛做了归档处理,并且可以在线访问。当时我正在他们的网站上闲游,直到发现了下载旧论坛消息的方法。这里演示的处理方法比Google Groups的例子要多,比如数据的自动化收集。

第一步:收集指向HTML文件的RSS信息

DocuSign开发者论坛中有几千条消息。我们可以组织一份URL列表来指向所有的消息或是讨论主题,这样就可以用代码实现自动下载,更高效地提取回复时间。

要实现这个功能,首先需要的是拿到所有讨论主题的URL。我发现DocuSign在Dev-Zone开发者站点上的归档文件地址为https://community.docusign.com/t5/DevZone-Archives-Read-Only/ct-p/dev_zone

网站看起来和下面的浏览器截图一样:

{%}

我们当然不想挨个点击论坛中的每一个链接,然后手工把每条消息保存下来。要是那样做的话,差不多得花一辈子时间,太烦了。难道就没有别的什么好办法了吗?

DocuSign网站的帮助页面已经表明,通过下载Really Simple Syndication(RSS)文件可以获取每个论坛中最新发布的主题和消息。这样的话就可以利用RSS文件自动收集网站上各种讨论话题的URL信息。而我们最为关注的RSS文件只有开发者支持论坛(并非广告或销售论坛)。这些RSS文件可以从下面的地址获取:

我们可以通过浏览器访问列表中的每一个URL(或者只访问其中的一个)。由于文件是RSS格式的,所以看起来像是带有标签的半结构化文本文件,与HTML比较类似。把访问到的RSS文件保存到本地系统并为每个文件添加一个.rss文件扩展名。在整个处理结束的时候,你应该拿到至少七个RSS文件,每一个文件能找到与上面列表中相对应的URL。

每个RSS文件的内容都是关于论坛中各个讨论话题的元数据,当中包括一些在这个阶段我们迫切需要的数据:每个讨论话题对应的URL地址。用文本编辑器随便打开其中一个RSS文件,你就可以定位到我们需要的URL。具体形式看起来应该跟下面的例子差不多,从文件的内部可以看出,每个讨论话题都含有这样的数据:

<guid>http://community.docusign.com/t5/Misc-Dev-Archive-READ-ONLY/
Re-Custom-CheckBox-Tabs-not-marked-when-setting-value-to-quot-X/m-
p/28884#M1674</guid>

现在我们可以编写程序来遍历每一个RSS文件,从而找出与之关联的URL数据,接着访问这些地址,提取出我们希望得到的回复时间。接下来,我们会把这个过程划分为一系列小步骤,并在后面使用程序来演示如何完成整个工作。

第二步:从RSS中提取URL,收集并解析HTML

在这一步中,我们将编写一个程序来完成以下步骤。

(1) 打开我们在第一步中保存下来的每一个RSS文件。

(2) 每当碰到<guid></guid>的标签组合的时候,就提取其中的URL信息并把它添加到一个列表中。

(3) 根据列表中的每一个URL,下载其对应的HTML文件。

(4) 读取HTML文件内容,并抽取原始消息的发布时间和对应的每一条回复消息的回复时间。

(5) 计算平均差、中位差和标准差,这跟我们当时在第一部分中做的差不多。

下面的Python代码会完成这些步骤。看完代码之后我们将详细说明数据抽取部分的内容:

import os
import re
import urllib2
import datetime
import numpy

alllinks = []
timelist = []
for filename in os.listdir(os.getcwd()):
    if filename.endswith('.rss'):
        f = open(filename, 'r')
        linktext = ''
        linkurl = ''
        for line in f:
            # 找出讨论主题的的URL
            linktext = re.search('(<guid>)(.+?)(<\/guid>)', line)

            if linktext:
                linkurl= linktext.group(2)
                alllinks.append(linkurl)
        f.close()

mainmessage = ''
reply = ''
maindateobj = datetime.datetime.today()
replydateobj = datetime.datetime.today()
for item in alllinks:
    print "==="
    print "working on thread\n" + item
    response = urllib2.urlopen(item)
    html = response.read()
    # 定义一个用于匹配时间戳的正则表达式样式
    tuples = re.findall('lia-message-posted-on\">\s+<span
class=\"local-date\">\\xe2\\x80\\x8e(.*?)<\/span>\s+<span
class=\"local-time\">([\w:\sAM|PM]+)<\/span>', html)
    mainmessage = tuples[0]
    if len(tuples) > 1:
        reply = tuples[1]
    if mainmessage:
        print "main: "
        maindateasstr = mainmessage[0] + " " + mainmessage[1]
        print maindateasstr
        maindateobj = datetime.datetime.strptime(maindateasstr,
'%m-%d-%Y %I:%M %p')
    if reply:
        print "reply: "
        replydateasstr = reply[0] + " " + reply[1]
        print replydateasstr
        replydateobj = datetime.datetime.strptime(replydateasstr,
'%m-%d-%Y %I:%M %p')

        # 只针对有回复的数据进行时间差计算
        difference = replydateobj - maindateobj
        totalseconds = difference.total_seconds()
        timeinhours =  (difference.days*86400+difference.seconds)/3600
        if timeinhours > 1:
            print timeinhours
            timelist.append(timeinhours)

print "when all is said and done, in hours:"
print numpy.mean(timelist)
print numpy.std(timelist)
print numpy.median(timelist)

(1) 程序状态

在程序工作的过程中,它会打印出一些状态信息,这样我们就可以知道它是否还处在工作的状态中。状态信息与下面的内容类似,并且每次从RSS文件中找到URL时都会有这样的信息输出:

===
working on thread
http://community.docusign.com/t5/Misc-Dev-Archive-READ-ONLY/Can-you-
disable-the-Echosign-notification-in-Adobe-Reader/m-p/21473#M1156
main:
06-21-2013 08:09 AM
reply:
06-24-2013 10:34 AM
74

这里的74表示在当前这个话题中,第一条提问消息的发布时间与第一条回复消息的发布时间经过四舍五入之后的差值(约为三天又两个小时)。

(2) 程序输出

在得到结论的时候,程序会分别打印出平均差、标准差和以小时为单位的平均回复时间,这与我们在第一部分的Google Groups程序中做的一样:

when all is said and done, in hours:
695.009009009
2506.66701108
20.0

看起来DocuSign论坛上的回复时间比Google Groups还要慢一些。Google Groups需要18小时比,它则需要花费20个小时,不过好在这两个数字还是在比较接近的范围内。你的实验结果可能会有所不同,这是因为总有新的消息不断地加入实验文件。

(3) 数据提取

我们的重点是数据提取。下面是最为重要的代码:

tuples = re.findall('lia-message-posted-on\">\s+<span class=\"local-
date\">\\xe2\\x80\\x8e(.*?)<\/span>\s+<span class=\"local-
time\">([\w:\sAM|PM]+)<\/span>', html)

就像前面的那些例子一样,这段代码也是依靠正则表达式实现的。但这里的表达式看起来乱得一塌糊涂。也许我们用BeautifulSoup会有所改善吧?那就让我们先看一下原始的HTML内容,这样就可以充分理解这段代码的含义,并能更好地判断是否应该换一种实现方式。下面的截图显示的是页面在浏览器中的样子。我们需要的时间信息在截图中加上了注释:

{%}

底层的HTML又是什么样子呢?那正是我们的程序需要解析的。从HTML的内容中我们可以发现,原始消息的日期信息分布在几处不同的地方,但原始消息和回复消息的日期和时间组合只在页面上打印了一次。下面是HTML的内容(为了方便查看,HTML经过简单的压缩处理,并移除了一些多余的空行):

<p class="lia-message-dates lia-message-post-date lia-component-post-
date-last-edited" class="lia-message-dates lia-message-post-date">
<span class="DateTime lia-message-posted-on lia-component-common-
widget-date" class="DateTime lia-message-posted-on">
<span class="local-date">06-18-2013</span>
span class="local-time">08:21 AM</span>

<p class="lia-message-dates lia-message-post-date lia-component-post-
date-last-edited" class="lia-message-dates lia-message-post-date">
<span class="DateTime lia-message-posted-on lia-component-common-
widget-date" class="DateTime lia-message-posted-on">
<span class="local-date">06-25-2013</span>
<span class="local-time">12:11 AM</span>

结果内容明显可以用正则表达式来解决,只要我们编写一个正则表达式样式就可以一次性找到这两种类型的消息。在代码里,我们把第一次找到的内容当作原始消息处理,而接下来的则当作回复消息处理,请看下面的代码:

mainmessage = tuples[0]
if len(tuples) > 1:
    reply = tuples[1]

我们本来也可以采用BeautifulSoup这样基于树形结构的解决方案,但这就不得不处理两套一模一样的日期组合,因为它们对应的span标签样式是完全相同的,即使我们将解析范围扩展到上一层父元素(<p>标签),也要面对相同样式的问题。所以,解析这样的树形结构要比之前的方法二复杂得多。

如果你就是想用BeautifulSoup来完成数据提取,我的建议是先用浏览器的Developer Tools功能好好观察一下页面的结构,比如在Chrome浏览器里,你可以将鼠标移到你想要查看的元素上——也就是这个项目中的日期和时间部分——点击鼠标右键,然后选择Inspect Element。这样就会打开一个Developer Tools面板,你可以从完整的文档树中找到对应数据的所在位置。每个HTML元素左侧的箭头符号表明该元素是否还有子节点。这个时候,你可以决定如何以编程的方式来定位树形结构中的目标元素,并进一步做好辨别不同节点的计划。这项任务的细节无法在此尽述,读者可以把它留作学后练习。

本文节选自《干净的数据:数据清洗入门与实践》

 

{%}

《干净的数据:数据清洗入门与实践》从文件格式、数据类型、字符编码等基本概念讲起,通过真实的示例,探讨如何提取和清洗关系型数据库、网页文件和PDF文档中的数据。最后提供了两个真实的项目,让读者将所有数据清洗技术付诸实践,完成整个数据科学过程。