最近接手了一个全网爬虫的工作项,基于 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 上了)。