首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >自动添加标签(2):再次实现

自动添加标签(2):再次实现

作者头像
不可言诉的深渊
发布2019-07-26 16:45:15
1.6K0
发布2019-07-26 16:45:15
举报

自动添加标签(1):初次实现

5.再次实现

你从初次实验中学到了什么呢?为了提高可扩展性,需提高程序的模块化程度(将功能放在独立的组件中)。要提高模块化程度,方法之一是采用面向对象设计。你需要找出一些抽象,让程序在变得复杂时也易于管理。下面先来列举一些潜在的组件。

  • 解析器:添加一个读取文本并管理其他类的对象。
  • 规则:对于每种文本块,都制定一条相应的规则。这些规则能够检测不同类型的文本块并相应地设置其格式。
  • 过滤器:使用正则表达式来处理内嵌元素。
  • 处理程序:供解析器用来生成输出。每个处理程序都生成不同的标记。

这里的设计虽然不太详尽,但至少让你知道应如何将代码分成不同的部分,并让每部分都易于管理。

5.1.处理程序

先来看处理程序。处理程序负责生成带标记的文本,并从解析器那里接受详细指令。假设对于每种文本块,他都提供两种处理方法:一个用于添加起始标签,另一个用于添加结束标签。例如,它可能包含用于段落处理的方法start_paragraph和end_paragraph。生成HTML代码时,可像下面实现这些方法:

当然,对于其他类型文本块,需要提供类似的处理方法。这好像足够灵活了:要添加其他类型的标记,只需在创建相应的处理程序(或渲染程序),并在其中添加相应起始标签和结束标签的方法。


注意 这里之所以使用术语处理程序(而不是渲染程序等),旨在指出它负责处理解析器生成的方法调用,而不必像HTMLRenderer那样使用标记语言渲染文本。XML解析方案SAX也使用了类似的处理程序机制。


如何处理正则表达式呢?你可能还记得,函数re.sub可通过第二个参数接受一个函数(替换函数)。这样将对匹配的对象调用这个函数,并将返回值插入文本中。这与前面讨论的处理程序理念很匹配——你只需让处理程序实现替换函数即可。例如,可像下面这样处理要突出的内容:

如果你不知道方法group是做什么的,应复习一下模块re。

除start、end和sub方法,还有一个名为feed的方法,用于向处理程序提供实际文本。在简单的HTML渲染程序中,只需像下面这样实现这个方法:

5.2.处理程序的超类

为提高灵活性,我们来添加一个Handler类,它将是所有处理程序的超类,负责管理一些管理性细节。在有些情况下,不通过全名调用方法(如start_paragraph),而是使用字符串表示文本块类型(如'paragraph')并将这样的字符串提供给处理程序将很有用。为此,可添加一些通过方法,如start(type)、end(type)和sub(type)。另外,还可以让通过方法start、end和sub检查是否实现了相应的方法(例如,start('paragraph')检查是否实现了start_paragraph)。如果没有实现,就什么都不做。这个Handler类的实现如下:

对于这些代码,有几点需要说明:

  • 方法callback负责根据指定的前缀(如'start_')和名称(如'paragraph')查找相应的方法,这是通过使用getattr并将默认值设置为None实现的。如果getattr返回的对象是可调用的,就使用额外的提供参数调用它。例如,调用handler.callback('start_', 'paragraph')时,将调用方法handler.start_paragraph且不提供任何参数——如果start_paragraph存在的话。
  • 方法start和end都是辅助方法,他们分别使用前缀start_和end_调用callback。
  • 方法sub稍有不同。它不直接调用callback,而是返回一个函数,这个函数将作为替换函数传递给re.sub(这就是它只接受一个匹配对象作为参数的原因所在)。

下面来看一个示例。假设HTMLRenderer是Handler的子类,并像前一节介绍的那样实现了方法sub_emphasis。现在假设变量handler存储着一个HTMLRenderer实例。

在这种情况下,调用handler.sub('emphasis')的结果将如何呢?

将返回一个函数(substitution)。如果你调用这个函数,它将调用方法handler.sub_emphasis。这意味着可在re.sub语句中使用这个函数:

太神奇了!(这里的正则表达式与用星号括起的文本匹配,将在稍后讨论。)但为何要这么绕呢?为何不像初次实现中那样使用r'<em>\1</em>'呢?因为如果这样做,就只能添加em标签,但你希望处理程序能够根据情况添加不同的标签。例如,如果处理程序为(虚构的)LaTeXRenderer,应生成完全不同的结果。

我们还提供了备用方案,以应对没有实现替换函数的情形。方法callback查找方法sub_something,但如果没有找到,就返回None。由于要返回一个用于re.sub中的替换函数,因此你不想返回None。相反,如果没有找到替换函数,就原样返回匹配对象。换而言之,如果callback返回None,在sub中定义的substitution将返回匹配的文本,即match.group(0)。

5.3.规则

至此,处理程序的可扩展性和灵活性都非常高了,该将注意力转向解析(对文本进行解读)了。为此,我们将规则定义为独立的对象,而不像初次实现那样使用一条包含各种条件和操作的大型if语句。

规则是供主程序(解析器)使用的。主程序必须根据给定的文本块选择合适的规则来对其进行必要的转换。换而言之,规则必需具备如下功能。

  • 知道自己适用于哪种文本块(条件)。
  • 对文本块进行转换(操作)。

因此每个规则对象都必须包含两个方法:condition和action。

方法condition只需要一个参数:待处理的文本块。它返回一个布尔值,指出当前规则是否适用于处理指定的文本块。


提示 要实现复杂的解析规则,可能需要让规则对象能够访问一些状态变量,从而让它知道之前发生的情况或已应用了哪些规则。


方法action也将当前文本块作为参数,但为了影响输出,它还必须能够访问处理器对象。

在很多情况下,适用的规则可能只有一个。换而言之,发现使用了标题规则(这表明当前文本块为标题)后,就不应再试图使用段落规则。为实现这一点,一种简单的方法是让解析器依次尝试每个规则,并在触发一个规则后不再接着尝试。这样做通常很好,但在有些情况下,应用一个规则后还可以应用其他规则。有鉴于此,需要给方法action再添加一项功能:让它返回一个布尔值,指出是否就此结束对当前文本块的处理。(也可使用异常来实现这项功能,这种异常类似于迭代器的StopIteration机制。)

标题规则的伪代码可能类似于:

5.4.规则的超类

虽然并非一定要提供规则超类,但多个规则可能执行相同的操作:调用处理程序的方法start、feed和end,并将相应的类型字符串作为参数,再返回True(以结束对当前文本块的处理)。假设所有规则的子类都有一个type属性,其中包含类型字符串,则可像下面这样实现超类。(Rule类包含在模块rules中)

方法condition由各个子类负责实现。Rule类及其子类都放在模块rules中。

5.5.过滤器

你无需实现独立的过滤器类。由于Handler类包含方法sub,每个过滤器都可用一个正则表达式和一个名称(如emphasis和url)来表示。下一节介绍如何处理解析器时,你将看到这是如何实现的。

5.6.解析器

现在来讨论应用程序的核心部分:Parser类。它使用一个处理程序以及一系列规则和过滤器将纯文本文件转换为带标记的文件(这里是HTML文件)。这个类需要包含哪些方法呢?完成准备工作的构造函数、添加规则的方法、添加过滤器的方法以及对文件进行解析的方法。

下面是Parser类的代码:

虽然这个类需要理解的内容有很多,但大都不太复杂。构造函数将提供的处理程序赋给一个实例(属性),再初始化两个列表:一个规则列表和一个过滤器列表。方法add_rule在规则列表中添加一个规则。然而,方法add_filter所做的工作更多:与方法add_rule类似,它在过滤器列表中添加一个过滤器,但在此之前还要先创建过滤器。过滤器就是一个函数,它调用re.sub并将参数指定为合适的正则表达式(模式)和处理程序中的替换函数(handler.sub(name))。

方法parse虽然看起来有些复杂,但可能是最容易实现的,因为它只是完成一直计划要完成的任务。它以调用处理程序的方法start('document')开头,并以调用处理程序的方法end('document')结束。在这两个调用之间,它迭代文本文件中的所有文本块。对于每个文本块,他都应用过过滤器和规则。应用过滤器就是调用函数filter,并以文本块和处理程序作为参数,再将结果赋给变量block,如下所示:

block = filter(block, self.handler)

这能让每个过滤器都完成其任务,即将部分文本替换为带标记的文本(如将*this*替换为<em>this</em>)。

遍历规则时设计的逻辑要多些。对于每个规则,都使用一条if语句来检查它是否适用——这是通过调用rule.condition(block)实现的。如果规则适用,就调用rule.action,并将文本块和处理程序作为参数。前面说过,方法action返回一个布尔值,指出是否就此结束对当前文本块的处理。为结束对文本块的处理,将方法action的返回值赋给变量last,再在这个变量为True时退出for循环。

if last:

break


注意 可将这两条语句压缩成一条,以避免使用变量last。

if rule.action(block, self.handler):

break

是否这样做很大程度上取决于你的偏好。避免使用临时变量可让代码更简单,但使用临时变量可清晰地标识返回值。


5.7.创建规则和过滤器

至此,万事俱备,只欠东风——还没有创建具体的规则和过滤器。到目前为止你编写的大部分代码都旨在让规则和过滤器与处理程序一样灵活。你可编写多个独立的规则和过滤器,再使用方法add_rule和add_filter将它们添加到解析器中,同时确保在处理程序中实现了相应的方法。

通过一组复杂的规则,可处理复杂的文档,但我们将保持尽可能简单。只创建分别用于处理题目、其他标题和列表项的规则。应将相连的列表是为一个列表,因此还将创建一个处理整个列表的列表规则。最后,可创建一个默认规则,用于处理段落,即其他规则未处理的所有文本块。

下面以不太正式的方式定义了这些规则。

  • 标题是指包含一行的文本块,长度最多为70个字符。以冒号结束的文本块不属于标题。
  • 题目是文档中的第一个文本块,前提条件是它属于标题。
  • 列表项是以连字符(-)打头的文本块。
  • 列表以紧跟在非列表项文本块后面的列表项开头,以后面紧跟着非列表项文本块的列表项结束。

这些规则是根据我对文本文档结构的直觉制定的,你对文本文档结构的看法可能不同。另外,这些规则存在一些缺陷。例如,如果文档以列表项结尾怎么办?你完全可以改进这些规则。首先来定义标题规则:

这里将属性type设置成了字符串'heading',这个属性是供从Rule类继承而来的方法action使用的。方法condition核实文本块不包含换行符(\n)、长度不超过70且最后一个字符不是冒号。

题目规则与此类似,但只使用一次——用于处理第一个文本块。从此以后,它将忽略所有的文本块,因为其first属性已设置为False。

列表项规则的方法condition是根据前面的定义直接实现的。

它重新实现了方法action。相比于Rule的方法action,这个方法唯一的不同之处在于,它删除了文本块中的第一个字符(连字符),并删除了余下文本中多余的空白。标记会生成列表项目符号,因此不需要连字符。

到目前为止,所有规则的action方法都返回True。列表规则的action不能这样,因为它在遇到非列表项后面的列表项或列表项后面的非列表项时触发。由于他不实际标记这些文本块,而只是标记列表(一组列表项)的开始和结束位置,因此你不希望对文本块的处理到此结束,从而要让它返回False。

对于这个列表项规则,可能需要做进一步解释。它的方法condition总是返回True,因为你要检查所有文本块。在方法action中,需要处理两种不同的情况。

如果属性inside(指出当前是否位于列表内)为False(初始值),且列表项规则的方法condition返回True,就说明刚进入列表中。因此调用程序的start方法,并将属性inside设置为True。

相反,如果属性inside为True,且列表项规则的方法condition返回False,就说明刚离开列表项。因此调用处理程序的end方法,并将属性inside设置为False。

完成这些处理后,这个方法返回False,以继续根据其他规则对文本块进行处理。(当然,这意味着规则的排列顺序至关重要。)

最后一个规则是ParagraphRule,其方法condition总是返回True,因为这是默认使用的规则。这个规则是加入规则列表中的最后一个元素,对其他规则未处理的所有文本块进行处理。

过滤器就是正则表达式。我们来添加三个过滤器,分别用来找要突出的内容、URL和Email地址。为此,我们使用下面三个正则表达式:

r'\*(.+?)\*'

r'(http://[\.a-zA-Z/]+)'

r'([\.a-zA-Z]+@[\.a-zA-Z]+[a-zA-Z]+)'

第一个模式找出要突出的内容,它与两个星号括起的内容匹配(它要匹配尽可能少的内容,因此使用了问号)。第二个模式找出URL,它与这样的内容匹配:字符串'http://'(你可在这里添加其他协议)后跟一个或多个句点、字母或斜杠。(这个模式并不能与所有合法的URL匹配,你可对其进行改进。)最后,Email模式与这样的内容匹配:中间为@,@前面为字母和句点组成的序列,@后面也是句点和字母组成的序列,最后是字母组成的序列,从而不与以句点结束的内容匹配。(同样,你可对这个模式进行改进。)

5.8.整合起来

现在,只需创建一个Parser对象,并添加相关的规则和过滤器。下面就来这样做:创建一个在构造函数中完成初始化的Parser子类,在使用它来解析sys.stdin。可以向运行原型那样运行最终的程序。

python markup.py <test_input.txt> test_output.html

将前面的示例文本作为输入时,这个程序的运行结果如图所示。

相比初次实现,再次实现显然更复杂,涉及范围更广。值得花精力去实现这样的复杂性,因为创建出的程序更灵活、可扩展性更强。要对其进行修改,只需派生出子类并初始化既有的类,而不像原型那样需要推倒重来。

6.进一步探索

这个程序存在如下潜在的扩展空间。

  • 增加对表格的支持。为此,只需找到左对齐内容的边界,并将文本块分成多列。
  • 突出全部大写的单词。为此需要考虑缩略语、标点、姓名及其他首字母大写的单词。
  • 支持LATEX格式的输出。
  • 编写一个执行其他处理(而不是添加标记)的处理程序,如以某种方式对文档进行分析。
  • 创建一个脚本,将特定目录中的所有文本文件都自动转换为HTML文件。
  • 了解其他纯文本格式,如Markdown、reStructuredText或维基百科使用的格式。
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-07-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Python机器学习算法说书人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档