详情页智能解析算法的实现
本节中我们来动手实现详情页的提取算法。
准备工作
在 14.2 节,我们已经将案例页面的 HTML 代码保存成了文本文件 detail.html。这里我们主要会用 XPath 解析页面和操作节点,所以需要用到 lxml 库,如果尚未安装该库,可以参考 https://setup.scrape.center/xml 里面的说明。
定义如下代码,将 HTML 代码里面的字符转化成 lxml 里面的 HtmlElement 对象:
from lxml.html import HtmlElement, fromstring
html = open('detail.html', encoding='utf-8').read() element = fromstring(html=html)
这里的 element 其实就是整个网页对应的 HtmlElement 对象,它的根节点就是 html,我们在解析页面的时候会用到它,从中可以提取我们想要的标题、正文和发布时间。
提取标题
首先来实现标题的提取,根据 14.2 节的内容,提取分为 3 个步骤。
-
查找 meta 节点里的标题信息,如果能查到,那结果通常是非常准确的,直接返回即可。
-
查找 title 节点里的标题信息,由于 title 中通常会包含穴余信息,因此需要将查找结果和 h 节点中的内容做比对,以便得到更准确的结果。
-
如果上述两个步骤都不能得到有效结果,则可以直接用 title 节点中的内容作为结果(保底)。
当然,此逻辑还存在很多可以优化的地方,但应该能够应对大多数详情页的标题提取任务。接下来就用代码实现一下这个逻辑吧。
首先定义利用 XPath 从 meta 节点中提取标题的规则:
这里我们定义了一系列 XPath,用于匹配 meta 节点并提取 content 属性的值。然后我们实现一个 extract_by_meta 方法:
这里遍历了 METAS 的内容,然后依次进行匹配,如果能够匹配到结果,就直接返回。这里可以尽量把更常见和更精准的 XPath 放到 METAS 的前面,同时避免填写一些置信度较低的 XPath,以便提取出更准确的内容。
接下来,对于 title 节点,就是直接提取其纯文本内容;对于 h 节点,则是提取 h1 节点、h2 节点和 h3 节点的内容,通过基本的 XPath 表达式就可以实现。这部分的代码实现如下:
这里我们提取了 title 节点、h1 节点、h2 节点和 h3 节点的信息,然后返回了它们的纯文本内容其中 extract_by_title 方法返回的是字符串类型的内容,extract_by_h 方法返回的是包含 h 节点中所有纯文本内容的列表。
下面我们依次调用 3 个方法,看看针对这个案例,结果是怎样的:
运行结果如下:
可以观察到,3 个方法返回的结果差不多,都包含真实的标题信息,另外后两个结果中有一些不太一样的内容。如我们所料,title_extracted_by_meta 是完全正确的标题。
假设不存在和 meta 节点相匹配的结果,如何依靠 title_extracted_by_title 和 title_extracted_by_h 得到真实的标题呢?可以观察到,title_extracted_by_title 相对真正的标题多了网站名称,title_extracted_by_h 是 h 节点组成的列表,其中有一个是真正的标题。有了这两部分信息,只需要求得和 title_extracted_by_title 最相似的 h 节点的内容就可以了。
可以采取的解决方案有很多,例如直接使用最基本的相似度算法一一Jaccard 算法,即用两个字符串的交集字符数量除以两个字符串的并集字符数量。代码实现如下:
这里我们定义了一个 similarity 方法,它接收两个字符串:s1 和 s2。首先该方法将 s1 和 s2 的字符拆分为集合,然后求出两个集合的交集和并集,最后返回交集字符数量和并集字符数量的比值。我们来验证一下这个结果,如果 s1 和 s2 完全相同,那么返回的结果就是 1:如果 s1 和 s2 毫不相于那么返回的结果就是 0。这个算法并没有考虑学符的数量和重复度,因此存在一定的局限性,但用来求解一般情况下的相似度已经足够了,而且计算速度非常快。
接下来只需要遍历 title_extracted_by_h 的每个元素,然后找出和 title_extracted_by_title 相似度最高的那个,就是真正的标题了。如果遍历完依然没有结果,就用 title_extracted_by_title 作为最终结果。
综上,可以把提取标题的过程定义成一个方法 extract_title:
提取正文
终于轮到重头戏一提取正文了,我们一起来实现 14.2 节介绍的文本密度和符号密度的计算吧。
首先需要做一些预处理工作。html 节点内通常有很多噪声,非常影响正文内容的提取,script、style 这些内容不仅一定不会包含正文,还会严重影响文本密度的计算,所以有必要先定义一个预处理方法:
这里我们定义了一些规则,CONTENT_USELESS_TAGS 代表一些噪声节点,直接调用 strip_elements 方法把这些节点及其内容删除即可。CONTENT_STRIP_TAGS 中节点的文本内容是需要保留的,但是标签可以删掉。CONTENT_NOISE_XPATHS 代表一些很明显不是正文的节点,如评论、广告等,直接删除就好。
其中还调用了几个工具方法,这些方法的定义如下:
这里还对一些节点做了特殊处理。例如对 p 节点内部的 span 节点和 strong 节点,去掉其标签,只保留内容。对于没有子节点的 div 节点,则将其换成 p 节点。当然,如果大家再想到什么细节,可以继续优化。
预处理完毕之后,整个 element 因为没有了噪声和干扰数据,变得比较规整了。下一步,我们来实现文本密度、符号密度和最终分数的计算。
为了方便处理,我会把节点定义成一个 Python 对象,名字叫作 Element,它包含很多字段,代表某个节点的信息,例如文本密度、符号密度等。Element 的定义如下:
以下为其中包含的字段的简析。
-
id:节点的唯一 id。
-
tag_name:节点的标签值,例如 p、div、img 等。
-
number_of_char:节点的总字符数。
-
number_of_achar:节点内带超链接的字符数。
-
number_of_descendants:节点的子孙节点数。
-
number_of_a_descendants:节点内带链接的节点数,即 a 的子孙节点数。
-
number_of_p_descendants:节点内的 p 节点数。
-
number_of_punctuation:节点包含的标点符号数。
-
density_of_punctuation:节点的符号密度。
-
density_of_text:节点的文本密度。
-
density_score:最终评分。
这些字段都是我们计算最终节点评分需要的,在此列举几个字段的计算方法:
这里列举的几个计算方法,接收的参数都是Element对象,返回值是对应字段的结果。number_of achar方法用于获取节点内带超链接的字符数,实现流程是查找当前节点内所有的a节点,然后统计这些a节点内的字符数量;number_of_punctuation方法用于获取节点内标点符号的数量,实现流程是先获取节点内的所有文本,然后统计其中属于标点符号的字符,这里声明了标点符号的集合PUNCTUATION; density_oftext方法用于计算节点的文本密度,其计算规则和14.2节的公式完全一致,这里就是 number_of_char和number_of_a_char的差除以number_of_descendants和number_of_a_descendants的差;density_of_punctuation方法用于计算节点的符号密度,其计算规则也和14.2节的公式完全一致,即numberofchar和number_of_achar的差除以number_ofpunctuation加1。
通过这些方法,我们就可以计算Element对象的各个指标了,最重要的当属文本密度density_of text和符号密度density_of_punctuation。
最后一步是利用14.2节介绍的公式,计算节点的最终分数并选取分数最高的节点提取其文本内容,最终得到的结果就是正文内容。提取正文的方法定义如下:
这里定义了一个process方法,并向其中传入HTML根节点进行处理。首先调用preprocess4content 方法做预处理,然后调用descendants_of_body方法获取了body节点的所有子孙节点,赋值为 descendants。接着对descendants进行遍历,计算出各个子孙节点的文本密度、符号密度以及文本密度的标准差,最后求得分数density_score。
求得所有子孙节点的density_score之后,排序找出density_score最高的节点,然后提取其p节点的文本内容即为正文,如上代码中的最后一部分便实现了排序和提取过程。
调用process方法来提取示例新闻页面的正文,运行结果如下:
可以看到,正文被成功提取出来了。
提取发布时间
提取发布时间,一般根据两个内容,一个是 meta 节点,一个是匹配规则。
如果 meta 节点里包含发布时间的相关信息,那么通常就是对的,可信度非常高,提取出来并返回就行;如果不包含,就用正则表达式匹配一些时间规则来提取。
首先我们根据 meta 节点提取,下面列出了一些用来匹配发布时间的 XPath 规则:
以上规则都是通过经验总结得来的,可以自行添加或修改。
然后我们同样定义一个方法 extract_by_meta 来提取发布时间,它接收一个 HtmlElement 对象,该方法的定义如下:
这里其实就是遍历 METAS 中的 XPath 规则,然后查找整个 HtmlElement 对象中有没有与当前规则匹配的内容,例如:
//meta[starts-with(@property, "og:published_time")]/@content
这行代码就是查找 meta 节点中是否存在以 og:published_time 开头的 property 属性,如果有,就提取出其 content 属性的值。
假如我们的案例中刚好有一个 meta 节点的内容为:
<meta name="og:time" content="2019-02-2002:26:00">
经过处理,它会匹配到下面的 XPath 表达式:
//meta[starts-with(@name, "og:time")j/@content
其实 extract_by_meta 方法就成功匹配到时间信息了,提取出 2019-02-2002:26:00 这个值就是发布时间了。一般来说这个结果可信度非常高,可以直接将其返回作为最终的提取结果。
可是,并不是所有页面都会包含这个 meta 节点。如果不包含,就要尝试用一些时间匹配规则来提取,其实就是定义一些时间的正则表达式:
由于内容比较多,因此这里省略了部分内容。其实就是一些常见的日期格式,日期格式毕竟是有限的,所以通过一些有限的正则表达就能完成匹配。
接下来,定义一个正则搜索的方法:
这个方法中先查找了 element 的文本内容,然后对文本内容进行正则表达式搜索,符合条件的就直接返回。
最后,我们直接把提取发布时间的方法定义为:
extract_by_meta(element) or extract_by_regex(element)
这样就会优先根据 meta 节点提取,其次根据正则表达式提取。
另外,对于处在特殊位置的时间,可以对要处理的 HtmlElement 对象进行预处理,先排除一些干扰信息,以提高提取的正确率。
整合
现在规整一下,将提取标题、正文和发布时间的方法合并为 extract 方法,然后输出 JSON 格式的结果:
最后直接调用 extract 方法,运行结果如图 14-12 所示。
图14-12提取结果
至此,我们成功提取了示例页面的标题、正文和发布时间,并以 JSON 格式输出这些内容。由于整个提取算法实现起来比较复杂,因此本节对部分代码的逻辑做了简化,不过大家不用担心,我已经将以上提取算法封装成了一个完整的 Python 包,可以直接调用,感兴趣的话也可以查看其源码。包叫作 GerapyAutoExtractor,可以通过 pip3 工具安装:
pip3 install gerapy-auto-extractor
安装完成后就可以导入使用了,调用流程也非常简单:
from gerapy_auto_extractor import extract_detail from gerapy_auto_extractor.helpers import content, jsonify
html = content('detail.html')
print(jsonify(extract_detail(html)))
这里我们调用 content 方法读取了详情页的 HTML 代码,调用 extract_detail 方法提取了详情页的内容,并调用 jsonify 方法对提取结果进行格式化,运行结果同样如图14-12所示。
另外,GerapyAutoExtractor 包还在很多细节上对提取算法进行了优化,大家可以查看其说明来了解更多用法,或者直接查看源码来详细了解本节内容的实现流程。
总结
本节中我们介绍了详情页提取算法的代码实现,不同的内容对应不同的实现思路。本节代码见 https://github·com/Gerapy/GerapyAutoExtractor