parse

Python爬虫利器——lxml 和 xpath 表达式

最近要做下微信爬虫,之前写个小东西都是直接用正则提取数据就算了,如果需要更稳定的提取数据,还是使用 xpath 定位元素比较可靠。周末没事,从爬虫的角度研究了一下 python xml/html 相关的库。

Python 标准库中自带了 xml 模块,但是性能不够好,而且缺乏一些人性化的 API。相比之下,第三方库 lxml 是用 Cython 实现的,而且增加了很多实用的功能,可谓爬虫处理网页数据的一件利器。

严格来说,html 并不是 xml 的一种,不过 lxml 对于 xml 和 html 都有很好的支持,分别使用 lxml.etreelxml.html两个模块。

解析

网页下载下来以后是个 bytes 的形式,需要构造 DOM 树:


In [1]: html = """
   ...: <p>hello<span id="world">world</span></p>
   ...: """

In [2]: import lxml.html

In [3]: doc = lxml.html.fromstring(html)

In [4]: doc
Out[4]: <Element p at 0x1059aa408>

Element 结构

生成的树是一个设计很精妙的结构,可以把它当做一个对象访问当前节点自身的文本节点,可以把他当做一个数组,元素就是他的子节点,可以把它当做一个字典,从而遍历它的属性,下面演示了 lxml 的常见用法:

In [5]: doc.text
Out[5]: "hello"

In [6]: doc.tag
Out[6]: "p"

In [7]: doc[0].tag
Out[7]: "span"

In [11]: for k, v in doc[0].items():
    ...:     print(k, v)
    ...:
id world

In [12]: doc[0].get("id")
Out[12]: "world"

In [13]: doc[0].attrib
Out[13]: {"id": "world"}

遍历树的方法

doc 是一个树形结构,可以通过一些方法访问树中的其他节点:

In [14]: doc.getroottree()  # 返回树
Out[14]: <lxml.etree._ElementTree at 0x105360708>

In [19]: doc.getroottree().getroot()  # 返回根节点,这里是 lxml 自动生成的 html 节点
Out[19]: <Element html at 0x10599da98>

In [20]: doc.getparent()  # lxml 自动生成的 body 节点
Out[20]: <Element body at 0x1059a87c8>

In [21]: doc.getprevious()

In [22]: doc.getnext()

In [23]: doc.text_content()
Out[23]: "helloworld"

In [25]: lxml.html.tostring(doc, pretty_print=True, encoding="utf-8")
Out[25]: b"<p>hello<span id="world">world</span></p>\n"

注意因为我们给的是一个 html 的片段(<p>...</p>),所以 lxml 自动生成了 html 和 body 等节点已构成完整的 html 文档。

如果需要显式地指定生成一个 html 片段文档还是完整文档,可以分别使用:lxml.html.fragmentfromstring 和 lxml.html.documentfromstring 两个方法。

lxml 还有其他一些方法,都列在下面了:

Element.tail

  • Element.append(Element) 添加一个子元素
  • Element.set(‘attr’, value) 设置属性
  • Element.iter(tag_name) 遍历所有后系元素,可以使用 *
  • ElementTree.getelementpath(Element)
  • Element.getroottree() 返回对应的树
  • ElementTree.getpath(Element) 返回一个元素的 xpath
  • ElementTree.getroot() 返回根节点
  • HtmlElement.drop_tree() 删除当前节点下的所有节点,但是保留text
  • HtmlElement.drop_tag() 删除当前节点,但是保留它的子节点和text
  • HtmlElement.classes 返回类
  • HtmlElement.findclass(classname) 按照 class 查找 tag
  • HtmlElement.getelementby_id(id, *default) 按照 id 查找元素
  • HtmlElement.iterlinks() 遍历所有连接
  • HtmlElement.makelinksabsolute(baseurl=None, resolvebase_href=True) 把所有连接变成绝对链接
  • HtmlElement.resolvebasehref() 解析 base 标签
  • HtmlElement.rewritelinks(linkrepl_func) 替换所有的链接

XPath

XPath 实在太强大了,在定位元素方面绝对是秒杀 CSS 选择器。在 lxml 中,节点和树分别具有xpath 函数。

lxml 中的 xpath 方法,对于 xpath 表达式应该返回元素,总是返回一个数组,即使只有一个元素

In [24]: doc.xpath("//span/text()")
Out[24]: ["world"]

lxml 中的 xpath 函数支持变量

print(root.xpath("$text", text = "Hello World!"))
Hello World!

xpath may return ElementStringResult, which is not picklable, use xpath(smartstrings=False) to avoid this http://lxml.de/xpathxslt.html#xpath-return-values

lxml 还支持几个函数 find/findall,他们使用 ElementPath,是一种类似 xpath 的语言,感觉很是奇怪,lxml 的文档描述他是 xpath 的一个子集,暂时不看了。

常见问题

lxml 在遇到小于号的时候会出问题(按照标准,应该编码为 <),直接把后面的文档都丢了,但是浏览器兼容性比较好,不会有问题。

by default, the lxml parser is not very error-proof, the html5parser lib is behaves more like your web browser.

lxml.html.html5parser provides same interface with lxml.html

tricks and traps

python-readability 源码阅读

readability 是一个可以从杂乱无章的网页中抽取出无特殊格式,适合再次排版阅读的文章的库,比如我们常见的手机浏览器的阅读模式很大程度上就是采用的这个库,还有 evernote 的 webclipper 之类的应用也都是利用了类似的库。readability 的各个版本都源自readability.js这个库,之前尝试阅读过js版本,无关的辅助函数太多了,而且 js 的 dom api 实在称不上优雅,读起来晦涩难通,星期天终于有时间拜读了一下python-readability的代码。

readability核心是一个Document类,这个类代表了一个 HTML 文件,同时可以输出一个格式化的文件

几个核心方法和概念

summary

summary 方法是核心方法,可以抽取出一篇文章。可能需要对文章抽取多次才能获得符合条件的文章,这个方法的核心思想是:

  1. 第一次尝试抽取设定 ruthless,也就是强力模式,可能会误伤到一些标签
  2. 把给定的 input 解析一次并记录到 self.html,并去除所有的 script,sytle 标签,因为这些标签并不贡献文章内容
  3. 如果在强力模式,使用removeunlikelycandidates去掉不太可能的候选
  4. transformmisuseddivsintops把错误使用的 div 转换成 p 标签,这样就不用考虑 div 标签了,其实这步挺关键的。其实还有一些其他的处理需要使用。
  5. 使用score_paragraphs给每段(paragraph)打分
  6. 使用selectbestcandidates获得最佳候选(candidates)
  7. 选出最佳候选,如果选出的话,调用 get_article 抽取文章
  8. 如果没有选出,恢复到非强力模式再试一次,还不行的话就直接把 html 返回
  9. 清理文章,使用 sanitize 方法
  10. 如果得到的文章太短了,尝试恢复到非强力模式重试一次

强力模式和非强力模式的区别就在于是否调用了 removeunlikelycandidates

对于以上的核心步骤, 已经足够应付大多数比较规范的网页. 但是还是会有不少识别错误. 公司内部的改进做法在于:

此处省略1000个字。

下面按照在 summary 出场顺序依次介绍~

removeunlikelycandidates

匹配标签的 class 和 id,根据unlikelyCandidatesRe和okMaybeItsACandidate这个两个表达式删除一部分节点。

unlikelyCandidatesRe:combx|comment|community|disqus|extra|... 可以看出是一些边缘性的词汇
okMaybeItsACandidateRe: and|article|body|column|main|shadow... 可以看出主要是制定正文的词汇

transformmisuseddivsintoparagraphs

  1. 对所有的 div 节点,如果没有 divToPElementsRe 这个表达式里的标签,就把他转化为 p
  2. 再对剩下的 div 标签中,如果有文字的话,就把文字转换成一个 p 标签,插入到当前节点,如果子标签有 tail节点的话,也把他作为 p 标签插入到当前节点中
  3. 把 br 标签删掉

socore_node

  1. 按照tag、 class 和 id 如果符合负面词汇的正则,就剪掉25分,如果符合正面词汇的正则,就加上25分
  2. div +5 分, pre、td、backquote +3 分
  3. address、ol、ul、dl、dd、dt、li、form -3分
  4. h1-h6 th -5 分

score_paragraphs

  1. 首先定义常量,MIN_LEN 最小成段文本长度
  2. 对于所有的 p,pre,td 标签,找到他们的父标签和祖父标签,文本长度小于 MIN_LEN 的直接忽略
  3. 对父标签打分(score_node),并放入排序队列
  4. 祖父标签也打分,并放入排序队列
  5. 开始计算当前节点的内容分(content_socre) 基础分1分,按照逗号断句,每句一分,每100字母+1分,至少三分
  6. 父元素加上当前元素的分,祖先元素加上1/2
  7. 链接密度 链接 / (文本 + 链接)
  8. 最终得分 之前的分 * (1 – 链接密度)

注意,当期标签并没有加入 candidates,父标签和祖父标签才加入
累计加分,如果一个元素有多个 p,那么会把所有子元素的content score都加上

selectbestcandidate

就是 ordered 中找出最大的

get_article

对于最佳候选周围的标签,给予复活的机会,以避免被广告分开的部分被去掉,阈值是10分或者最佳候选分数的五分之一。如果是 p 的话,nodelength > 80 and linkdensity < 0.25 或者 长度小于80,但是没有连接,而且最后是句号

思考

readability之所以能够work的原因,很大程度上是基于html本身是一篇文档,数据都已将在html里了,然后通过操作DOM获得文章。而在前端框架飞速发展的今天,随着react和vue等的崛起,越来越多的网站采用了动态加载,真正的文章存在了页面的js中甚至需要ajax加载,这时在浏览器中使用readability.js虽然依然可以(因为浏览器已经加载出了DOM),但是如果用于抓取目的的话,需要执行一遍js,得到渲染过的DOM才能提取文章,如果能够有一个算法,直接识别出大段的文字,而不是依赖DOM提取文章就好了~