parse

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

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

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

严格来说,html 并不是 xml 的一种,两者都是 SGML 的衍生。不过 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"

需要特别注意的是 text_content() 函数,比如说:<p> Hello <br/> Word </p> 这个片段,我们想要获取他的 inner text

In [7]: lxml.html.fragment_fromstring("<p>Hello<br/>World</p>")
Out[7]: <Element p at 0x108e1b9a0>

In [8]: doc = lxml.html.fragment_fromstring("<p>Hello<br/>World</p>")

In [9]: doc.text
Out[9]: 'Hello'

In [10]: doc.text_content()
Out[10]: 'HelloWorld'

In [15]: doc.getchildren()
Out[15]: [<Element br at 0x108ea7630>]

In [16]: doc.getchildren()[0].tail
Out[16]: 'World'

注意因为我们给的是一个 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 在遇到小于号的时候会出问题(按照标准,应该编码为 &lt;),直接把后面的文档都丢了,但是浏览器兼容性比较好,不会有问题。

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

区分 HTML 和 XML

网页不规范,有多个标签,浏览器可以解析,但是 lxml 不可以解析。case:http://ggzyjy.quanzhou.gov.cn/govProcurement/govProcurementDetail.do?bltId=178813&centerId=-1

处理 html entity

在网页中经常出现 <, &amp;, &0x0026; 这些特殊字符,这是 html 实体字符转义,用于防止 XSS 攻击。Python3 标准库中包含了 html.entities 模块,可以用于转义和反转义这些字符。

html.entities.entitydefs 中包含了名称到符号的映射比如{"amp": "&"}
html.entities.name2codepoint 中包含了名称到数字的映射比如 {"amp": 0x0026}
html.entities.codepoint2name 中包含了数字到名称的映射比如 {0x0026: "amp"}

readability.js 源码阅读

Readability is able to fetch paginated page and combine them into one page
 

by functions

 

init

  1. start the whole process
  2. remove event listeners
  3. remove scripts
  4. find next page
  5. prep the document
  6. get readability article components
  7. get document directions
  8. add readability dom to the dom
  9. post process
  10. scroll to top
  11. append next page to the dom
  12. add some smooth scrolling function

parsedPages

stores the parsed pages, key are end-slash-striped

prepDocument

  1. create document.body if not have
  2. find the biggest frame (width + height)
  3. remove all css
  4. remove all style elements
  5. replace <br/><br> to </p><p>
     

    prepArticle

     

  6. Prepare the article node for display. Clean out any inline styles, iframes, forms, strip extraneous <p> tags, etc.
  7. clean styles
  8. clean unwanted tags
  9. if only have one h2, that must be the title, but we already have title, so remove it
  10. remove empty <p> s
     

    getArticleTitle

     

  11. get document.title or h1
  12. normalizing the title
     

    killBreaks

     
    replace any break (<br/> ) by <br />
     

    cleanTags

     

  13. clean child tags of given element
  14. cleanConditionally
     

    getLinkDensity

     
    the amount of text that is inside a link divided by the total text in the node. archored text length / all text length
     

    grabArticle

     
    main logic for readability, using a variety of metrics (content score, classname, element types), find the content that is most likely to be the stuff a user wants to read. Then return it wrapped up in a div.

get nodes to score:

  1. get all nodes
  2. remove unlikely candidates by find specific patterns in classname and id
  3. add p, td, pre to nodesToScore
  4. Turn all divs that don’t have children block level elements into p’s, and add it to nodesToScore

get candidates

  1. Loop through all paragraphs, and assign a score to them based on how content-y they look. Then add their score to their parent node.
  2. pass <25 char nodes
  3. initialize parent and grand parent nodes ?
  4. compute content score

    1. base score 1
    2. add score by comma numbers, note only english comma counted
    3. For every 100 characters in this paragraph, add another point. Up to 3 points.
    4. Add the score to the parent. The grandparent gets half.
       

      getCharCount

       
      Get the number of times a string s appears in the node e.
       

      htmlspecialchars

       
      replace <>&”‘ to safe strings
       

      flagisActive/addFlag/removeFlag

       
      check/ readability flags

removeScripts

 
remove all javascripts found one the page
 

getInnerText

 
trim and squeeze spaces and return the textContent of a node
 

cleanStyles

 
1. clean style attribute recursively

fixImageFloats

 
1. Some content ends up looking ugly if the image is too large to be floated.  If the image is wider than a threshold (currently 55%), no longer float it, center it instead.
 

postProcessContent

 
post processing: add footnotes, fix floating images
 

getArticleTools

 
1. get document.title or h1
2. normalizing the title
 

getSuggestedDirection

 

getArticleFooter

 
1. readability tracking script

addFootNotes

 
add links found in the page as foot notes
 

useRdbTypekit

 
nothing
 
xhr
 
xmlhttprequest
 
successfulrequest
 
ajax

findbaseUrl

 
find the articles base url, normalize and remove the paganation part
only the path part, no query string
 

findnextpage

 
1. find all links
2. if already seen the link or the link is the page self
3. if on different domain, ignore
4. if match the EXTRANEOUS regex or has a long text, remove it
5. if remove the base url, and have no number in it, remove it
 
ok, the logic is very good, just translate it to python
 

appendNextPage

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 提取文章就好了~