0%

处理 Scrapy 中网页编码问题

最近接手了一个全网爬虫的工作项,基于 Python 2.7 ,用到了 Scrapy 框架,同时也用了 newspaper 这个库(github 地址)来做基于标签密度的正文内容提取。鉴于之前一直运行良好,所以我也没有太在意这一块。后面事业部那边说最近发现好几个网站的网页都出现了中文乱码,让我处理一下这个问题。末了我也顺手把处理的经过记录一下,分享一下经验。

问题定位

正文解析的爬虫里,用到了在 newspaperArticle 提供了 download 的方法,然后调用 parse 方法进行解析。实际上因为已经由 Scrapy 框架来负责 download 的操作,所以在使用 download 方法时,是传入了编码后的网页内容作为参数避免二次下载(实际上 download 方法如果不带参数调用的话,它会把构造函数中的 url 丢给 requests 处理)。

因此乱码的原因应该是出在应用自身负责处理的网页编码部分上。原来项目里简单的用了 chardet 库来推断网页的编码格式,然而得到的结果是 'ISO-8859-9',而实际上用浏览器打开,其编码格式应该是 'GBK'。而直接使用 newspapaer.Article.download 去处理这个网页的 url,得到的也是非乱码的内容;同时结合 download 方法的源码发现,负责编码推断的,实际上是 requests 库:

1
2
3
4
5
6
7
8
9
10
html = None
response = requests.get(url=url,
**get_request_kwargs(timeout, useragent))
if response.encoding != FAIL_ENCODING:
html = response.text
else:
html = response.content
if html is None:
html = u''
return html

问题定位的结果: 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
2
3
4
5
6
7
8
9
10
11
12
13
14
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: SHIFT_JIS Japanese prober hit error at byte 280
...
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: GB2312 Chinese prober hit error at byte 19283
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: EUC-KR Korean prober hit error at byte 5162
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: CP949 Korean prober hit error at byte 5162
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: Big5 Chinese prober hit error at byte 19283
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: EUC-TW Taiwan prober hit error at byte 248
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: windows-1251 confidence = 0.00729265920251, below negative shortcut threshhold 0.05
...
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: MacCyrillic Russian confidence = 0.0902042896223
...
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: ISO-8859-9 Turkish confidence = 0.272154895656
...
2018-08-18 14:53:02 [chardet.charsetprober] DEBUG: windows-1255 not active

chardet 确实是把推断成功率最高的 ISO-8859-9 给我们指出来了(尽管只有 20% 的概率)。因为这个判断过程的确是第三方 chardet 的原生实现,即使知道这部分出现了问题,我们也不应该去干预其实现。因此解决方案要从成功的实现上去找:浏览器的编码解析requests 的编码解析

requests 如何推断网页编码

毫无疑问,从 requests 上去找解决方案会容易得多(因为 requests 是开源的而浏览器的实现可能各有各的区别)。

实际上,在 requests 的手册上,在 Response Content (响应内容) 一节中也稍微提及了一下它是如何处理网页编码的:

请求发出后,Requests 会基于 HTTP 头部对响应的编码作出有根据的推测。当你访问 r.text 之时,Requests 会使用其推测的文本编码。 …… 你可能希望在使用特殊逻辑计算出文本的编码的情况下来修改编码。比如 HTTP 和 XML 自身可以指定编码。这样的话,你应该使用 r.content 来找到编码,然后设置 r.encoding 为相应的编码。

这里的两个主要信息是:

  1. 实际上 requestsresponse 是根据 HTTP 头部来推测编码
  2. 对于 HTTP 和 XML 是可以根据 response.content 通过特殊逻辑得到编码

前者用到的是 requests.utils.get_encoding_from_headers ,在 requests 源码中 adapters.py 中有使用:

1
2
3
4
5
# Make headers case-insensitive.
response.headers = CaseInsensitiveDict(getattr(resp, 'headers', {}))

# Set encoding.
response.encoding = get_encoding_from_headers(response.headers)

后者用到的是同处于 utils.py 中的 requests.utils.get_encodings_from_content,根据网页内容来推测编码。

鉴于 scrapy.http.headers.Headerrequests 的 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
2
3
4
5
6
7
8
9
10
# decode 解释如下
decode(...)
S.decode([encoding[,errors]]) -> object

Decodes S using the codec registered for encoding. encoding defaults
to the default encoding. errors may be given to set a different error
handling scheme. Default is 'strict' meaning that encoding errors raise
a UnicodeDecodeError. Other possible values are 'ignore' and 'replace'
as well as any other name registered with codecs.register_error that is
able to handle UnicodeDecodeErrors.

解决方案

出现的坑都趟完,大体上可以给到解决方案了:

  1. 先对 scrapyresponse.header 做编码检测,如果不成功或编码为 ISO-8859-1(检测 Content-Type 为 text 的默认返回),进入步骤 2
  2. response.body 做编码检测,如果不成功,进入步骤 3
  3. 使用 chardet 解析,得到成功率最高的编码格式和语言,如果成功率较低,进入步骤 4
  4. 如果 languageChinese 的话,可以默认使用 gb18030 编码(gb18030gb2312gbk 的超集,可以应付大部分中文编码);否则默认使用 ISO-8859 的编码

延申内容

requests 3.0 版本中,get_encodings_from_content 会从 utils.py 中移除,详情可见 github 上的 issue#2266,原因大致是因为 contributors 认为 requests 应该是一个纯粹的 HTTP 库而不是 HTML 库,建议了 Kenneth Reitz 将这些和 HTML 相关的功能都迁移到 requests-toolbelt 中去(这个库也托管到 PyPI 上了)。