最近接手了一个全网爬虫的工作项,基于 Python 2.7 ,用到了 Scrapy 框架,同时也用了 newspaper 这个库(github 地址)来做基于标签密度的正文内容提取。鉴于之前一直运行良好,所以我也没有太在意这一块。后面事业部那边说最近发现好几个网站的网页都出现了中文乱码,让我处理一下这个问题。末了我也顺手把处理的经过记录一下,分享一下经验。
问题定位
正文解析的爬虫里,用到了在 newspaper 中 Article 提供了 download 的方法,然后调用 parse 方法进行解析。实际上因为已经由 Scrapy 框架来负责 download 的操作,所以在使用 download 方法时,是传入了编码后的网页内容作为参数避免二次下载(实际上 download 方法如果不带参数调用的话,它会把构造函数中的 url 丢给 requests 处理)。
因此乱码的原因应该是出在应用自身负责处理的网页编码部分上。原来项目里简单的用了 chardet 库来推断网页的编码格式,然而得到的结果是 'ISO-8859-9',而实际上用浏览器打开,其编码格式应该是 'GBK'。而直接使用 newspapaer.Article.download 去处理这个网页的 url,得到的也是非乱码的内容;同时结合 download 方法的源码发现,负责编码推断的,实际上是 requests 库:
| 1 | html = None | 
问题定位的结果: chardet 的编码推断有误,而 requests 则正常,解决办法应该从 requests 中找。
chardet 为什么会出错
要知道 chardet 为什么会出错,首先要知道 chardet 是什么。
做过 Python 开发,尤其是涉及文本或者字符串一类应用比较多的开发人员应该都知道 Python 的编码是个大坑。对于 Python 中 unicode 和 str 之间的转换,虽然提供了 decode 以及 encode 方法,但是如果不知道编码是不好做的,因此我们需要 “猜测” 编码,而 chardet 就是提供了这样一个功能的第三方库:它根据各种编码的特征字符来判断未知文本的编码格式 (所推断得到的编码均是 Mozilla 规定的编码)。
chardet 的安装和使用都非常简单 —— 只要将 bytes 类型的参数传入 chardet.detect 方法,就会得到一个 dict 字典,其中包含三个键值:
- encoding:字符串类型,表示推断的编码格式
- confidence:浮点数类型,表示推断为正确的概率
- language:字符串类型,表示使用的语言(因为检测编码,很多情况下就是检测语言)
实际上,当我们把 scrapy.http.response.html.HtmlResponse.body 的 bytes 类型网页内容传给 chardet.detect ,从控制台日志可以发现:
| 1 | 2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: SHIFT_JIS Japanese prober hit error at byte 280 | 
chardet 确实是把推断成功率最高的 ISO-8859-9 给我们指出来了(尽管只有 20% 的概率)。因为这个判断过程的确是第三方 chardet 的原生实现,即使知道这部分出现了问题,我们也不应该去干预其实现。因此解决方案要从成功的实现上去找:浏览器的编码解析 和 requests 的编码解析。
requests 如何推断网页编码
毫无疑问,从 requests 上去找解决方案会容易得多(因为 requests 是开源的而浏览器的实现可能各有各的区别)。
实际上,在 requests 的手册上,在 Response Content (响应内容) 一节中也稍微提及了一下它是如何处理网页编码的:
请求发出后,Requests 会基于 HTTP 头部对响应的编码作出有根据的推测。当你访问 r.text 之时,Requests 会使用其推测的文本编码。 …… 你可能希望在使用特殊逻辑计算出文本的编码的情况下来修改编码。比如 HTTP 和 XML 自身可以指定编码。这样的话,你应该使用 r.content 来找到编码,然后设置 r.encoding 为相应的编码。
这里的两个主要信息是:
- 实际上 requests的response是根据 HTTP 头部来推测编码
- 对于 HTTP 和 XML 是可以根据 response.content通过特殊逻辑得到编码
前者用到的是 requests.utils.get_encoding_from_headers ,在 requests 源码中 adapters.py 中有使用:
| 1 | # Make headers case-insensitive. | 
后者用到的是同处于 utils.py 中的 requests.utils.get_encodings_from_content,根据网页内容来推测编码。
鉴于 scrapy.http.headers.Header 和 requests 的 header 都是类字典对象,尝试对前者调用 requests.utils.get_encoding_from_headers,得到的是 'GBK' ,也的确是和浏览器一样的解析编码。得到了正确的解析编码,之后的事情就是 decode 和 encode 处理了。
Python 中的 decode
如果我们使用 response.body.decode('gbk').encode('utf-8') 将网页编码统一处理为 UTF8 的时候,发现运行的结果依然会报错:
| 1 | {UnicodeDecodeError}'gbk' codec can't decode bytes in position 19282-19283: illegal multibyte sequence | 
即便使用了正确的编码(gbk)也无法将 bytes 转换为 unicode 格式,看上去非常奇怪。但是这确实是非常常见的事情,原因在于使用了非法字符。
…… 在某些用 C/C++ 编写的程序中,全角空格往往有多种不同的实现方式,比如
\xa3\xa0,或者\xa4\x57,这些字符,看起来都是全角空格,但它们并不是 “合法” 的全角空格(真正的全角空格是\xa1\xa1)
而默认情况下,只要出现一个非法字符,就会导致 Python 全部都无法转码。
对此,Python 的解决方法是给 decode 方法以第二个参数 errors 来控制错误处理策略(详见《流畅的 Python》 第四章):
| errors 取值 | 说明 | 
|---|---|
| strict | 默认取值,遇到非法字符时抛出异常 | 
| ignore | 忽略遇到的非法字符 | 
| replace | 用 ? 替换遇到的非法字符 | 
| xmlcharrefreplace | 用 XML 的字符引用替换遇到的非法字符 | 
| 1 | # decode 解释如下 | 
解决方案
出现的坑都趟完,大体上可以给到解决方案了:
- 先对 scrapy的response.header做编码检测,如果不成功或编码为 ISO-8859-1(检测 Content-Type 为 text 的默认返回),进入步骤 2
- 对 response.body做编码检测,如果不成功,进入步骤 3
- 使用 chardet解析,得到成功率最高的编码格式和语言,如果成功率较低,进入步骤 4
- 如果 language为Chinese的话,可以默认使用gb18030编码(gb18030为gb2312和gbk的超集,可以应付大部分中文编码);否则默认使用ISO-8859的编码
延申内容
在 requests 3.0 版本中,get_encodings_from_content 会从 utils.py 中移除,详情可见 github 上的 issue#2266,原因大致是因为 contributors 认为  requests 应该是一个纯粹的 HTTP 库而不是 HTML 库,建议了 Kenneth Reitz 将这些和 HTML 相关的功能都迁移到 requests-toolbelt 中去(这个库也托管到 PyPI 上了)。